bbox-mcp-server 1.2.6 → 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})`);
450
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})`);
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
+ }
647
+ }
648
+ if (current.trim()) {
649
+ statements.push(current.trim());
455
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,15 +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."
909
+ },
910
+ return_geometry: {
911
+ type: "boolean",
912
+ description: "If true, returns full geometry (polygon/polyline) for ways and relations instead of center point. Defaults to false."
622
913
  }
623
914
  },
624
915
  required: ["query"],
@@ -630,13 +921,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
630
921
  },
631
922
  {
632
923
  name: "list_osm_tags",
633
- 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.",
634
925
  inputSchema: {
635
926
  type: "object",
636
927
  properties: {
637
928
  category: {
638
929
  type: "string",
639
- 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')."
640
931
  }
641
932
  },
642
933
  required: ["category"]
@@ -663,6 +954,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
663
954
  resolution: {
664
955
  type: "number",
665
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."
666
965
  }
667
966
  },
668
967
  required: ["query", "resolution"],
@@ -681,23 +980,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
681
980
  try {
682
981
  if (toolName === "get_bounds") {
683
982
  const args = request.params.arguments || {};
983
+ const warnings = [];
684
984
  let bboxObj;
985
+ let geocodeRes;
685
986
  if (args.location) {
686
987
  try {
687
- bboxObj = await geocodeLocation(args.location);
988
+ geocodeRes = await geocodeLocation(args.location);
989
+ bboxObj = geocodeRes.bbox;
688
990
  }
689
991
  catch (err) {
690
992
  if (!args.bbox) {
691
993
  log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
692
994
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
693
995
  }
694
- // User provided bbox fallback
695
996
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
696
- bboxObj = parseBBox(args.bbox);
997
+ bboxObj = parseBBox(args.bbox, warnings);
697
998
  }
698
999
  }
699
1000
  else if (args.bbox) {
700
- bboxObj = parseBBox(args.bbox);
1001
+ bboxObj = parseBBox(args.bbox, warnings);
701
1002
  }
702
1003
  else {
703
1004
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
@@ -705,14 +1006,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
705
1006
  let epsg = args.epsg || "4326";
706
1007
  let format = args.format || "csv";
707
1008
  let coordOrder = args.coord_order || "lng,lat";
708
- let zoom = args.zoom || 15;
709
- let precision = args.precision || 6;
1009
+ let zoom = args.zoom ?? 15;
1010
+ let precision = args.precision ?? 6;
710
1011
  epsg = epsg.replace(/[^0-9]/g, "");
711
- // Dynamically fetch projection if not bundled
712
1012
  await ensureProjection(epsg);
713
1013
  const projCoords = projectBounds(bboxObj, epsg);
714
1014
  const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
715
- // Build projected bbox for formatting (uses projected values)
716
1015
  const projBbox = {
717
1016
  lat1: projCoords.ymin,
718
1017
  lng1: projCoords.xmin,
@@ -723,20 +1022,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
723
1022
  const shareUrl = buildShareUrl(bboxObj);
724
1023
  const dims = calculateBboxDimensions(bboxObj);
725
1024
  const areaKm2 = calculateBboxArea(bboxObj);
726
- // Calculate center and tile indices based on WGS84 original bounds
727
1025
  const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
728
1026
  const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
729
1027
  const tileIndices = latLngToTile(centerLat, centerLng, zoom);
730
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';
731
1033
  return {
732
1034
  content: [
733
1035
  {
734
1036
  type: "text",
735
- 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)}`
736
1038
  },
737
1039
  {
738
1040
  type: "text",
739
1041
  text: JSON.stringify({
1042
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
740
1043
  original_wgs84: bboxObj,
741
1044
  projected: projCoords,
742
1045
  center: {
@@ -763,35 +1066,64 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
763
1066
  const args = request.params.arguments || {};
764
1067
  const resolution = args.resolution;
765
1068
  const compact = args.compact || false;
1069
+ const summaryOnly = args.summary_only || false;
766
1070
  const returnGeometry = args.return_geometry || false;
1071
+ const warnings = [];
767
1072
  if (resolution === undefined) {
768
1073
  return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
769
1074
  }
770
1075
  let bboxObj;
1076
+ let geocodeRes;
771
1077
  if (args.location) {
772
1078
  try {
773
- bboxObj = await geocodeLocation(args.location);
1079
+ geocodeRes = await geocodeLocation(args.location);
1080
+ bboxObj = geocodeRes.bbox;
774
1081
  }
775
1082
  catch (err) {
776
1083
  if (!args.bbox) {
777
1084
  log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
778
1085
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
779
1086
  }
780
- bboxObj = parseBBox(args.bbox);
1087
+ bboxObj = parseBBox(args.bbox, warnings);
781
1088
  }
782
1089
  }
783
1090
  else if (args.bbox) {
784
- bboxObj = parseBBox(args.bbox);
1091
+ bboxObj = parseBBox(args.bbox, warnings);
785
1092
  }
786
1093
  else {
787
1094
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
788
1095
  }
789
1096
  const cells = getH3Cells(bboxObj, resolution, compact);
790
1097
  const shareUrl = buildShareUrl(bboxObj);
791
- 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
+ }
792
1123
  const responseData = {
793
1124
  resolution,
794
1125
  compact,
1126
+ summary_only: false,
795
1127
  cell_count: cells.length,
796
1128
  cells,
797
1129
  original_bbox: bboxObj,
@@ -804,7 +1136,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
804
1136
  content: [
805
1137
  {
806
1138
  type: "text",
807
- 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)}`
808
1140
  },
809
1141
  {
810
1142
  type: "text",
@@ -816,17 +1148,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
816
1148
  else if (toolName === "generate_share_url") {
817
1149
  const args = request.params.arguments || {};
818
1150
  const bbox = args.bbox;
1151
+ const warnings = [];
819
1152
  if (!bbox) {
820
1153
  return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
821
1154
  }
822
- const bboxObj = parseBBox(bbox);
1155
+ const bboxObj = parseBBox(bbox, warnings);
823
1156
  const shareUrl = buildShareUrl(bboxObj);
824
1157
  log('info', `generate_share_url completed`, { share_url: shareUrl });
825
1158
  return {
826
1159
  content: [
827
1160
  {
828
1161
  type: "text",
829
- text: `View on map: ${shareUrl}`
1162
+ text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
830
1163
  },
831
1164
  {
832
1165
  type: "text",
@@ -838,11 +1171,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
838
1171
  ]
839
1172
  };
840
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
+ }
841
1197
  else if (toolName === "search_overpass") {
842
1198
  const args = request.params.arguments || {};
843
1199
  const queryRaw = args.query;
844
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;
845
1204
  const radiusMeters = args.radius_meters;
1205
+ const returnGeometry = args.return_geometry || false;
846
1206
  const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
847
1207
  if (!queryRaw) {
848
1208
  return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
@@ -853,20 +1213,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
853
1213
  if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
854
1214
  return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
855
1215
  }
1216
+ const warnings = [];
856
1217
  let overpassQuery;
857
1218
  let searchModeLabel;
858
- let centerPoint;
1219
+ let refPoint;
859
1220
  let bboxObj;
1221
+ let geocodeRes;
1222
+ let usedAreaId;
860
1223
  if (useCircle) {
861
1224
  if (args.location) {
862
1225
  try {
863
- 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}"` };
864
1228
  }
865
1229
  catch (err) {
866
1230
  if (args.bbox) {
867
1231
  log('warn', `Geocoding failed for radius search, using bbox center`, { error: err.message });
868
- const fb = parseBBox(args.bbox);
869
- 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" };
870
1234
  }
871
1235
  else {
872
1236
  log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
@@ -875,24 +1239,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
875
1239
  }
876
1240
  }
877
1241
  else if (args.bbox) {
878
- const fb = parseBBox(args.bbox);
879
- 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" };
880
1244
  }
881
1245
  else {
882
1246
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
883
1247
  }
884
- overpassQuery = `[out:json][timeout:25];\n(${queryRaw}(around:${radiusMeters},${centerPoint.lat},${centerPoint.lng}););\nout center;`;
885
- searchModeLabel = `circle — ${radiusMeters}m radius around (${centerPoint.lat.toFixed(5)}, ${centerPoint.lng.toFixed(5)})`;
1248
+ const outDirective = returnGeometry ? "out geom;" : "out center;";
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)})`;
886
1253
  }
887
1254
  else {
888
1255
  if (args.location) {
889
1256
  try {
890
- 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
+ }
891
1263
  }
892
1264
  catch (err) {
893
1265
  if (args.bbox) {
894
1266
  log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
895
- 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" };
896
1269
  }
897
1270
  else {
898
1271
  log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
@@ -901,15 +1274,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
901
1274
  }
902
1275
  }
903
1276
  else if (args.bbox) {
904
- 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" };
905
1279
  }
906
1280
  else {
907
1281
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
908
1282
  }
909
- overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
910
- searchModeLabel = `bounding box`;
1283
+ const outDirective = returnGeometry ? "out geom;" : "out center;";
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
+ }
911
1294
  }
912
- 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 });
913
1296
  const endpoints = [
914
1297
  "https://overpass-api.de/api/interpreter",
915
1298
  "https://overpass.kumi.systems/api/interpreter"
@@ -921,9 +1304,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
921
1304
  let lastError = null;
922
1305
  const params = new URLSearchParams();
923
1306
  params.append('data', overpassQuery);
1307
+ const clientTimeoutMs = (timeoutSec + 5) * 1000;
924
1308
  for (const endpoint of endpoints) {
925
1309
  const controller = new AbortController();
926
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
1310
+ const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
927
1311
  try {
928
1312
  log('info', `Executing Overpass query at ${endpoint}`);
929
1313
  const response = await fetch(endpoint, {
@@ -932,7 +1316,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
932
1316
  headers: {
933
1317
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
934
1318
  'Accept': 'application/json',
935
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
1319
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
936
1320
  },
937
1321
  signal: controller.signal
938
1322
  });
@@ -952,60 +1336,89 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
952
1336
  }
953
1337
  if (!data) {
954
1338
  if (lastError?.name === 'AbortError') {
955
- 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 };
956
1340
  }
957
1341
  throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
958
1342
  }
959
1343
  const seenIds = new Set();
960
1344
  let parsedElements = (data.elements || []).reduce((acc, e) => {
961
- if (seenIds.has(e.id))
1345
+ const dedupKey = `${e.type}/${e.id}`;
1346
+ if (seenIds.has(dedupKey))
962
1347
  return acc;
963
- seenIds.add(e.id);
964
- const lat = e.lat ?? e.center?.lat;
965
- const lon = e.lon ?? e.center?.lon;
1348
+ seenIds.add(dedupKey);
1349
+ const lat = e.lat ?? e.center?.lat ?? e.geometry?.[0]?.lat;
1350
+ const lon = e.lon ?? e.center?.lon ?? e.geometry?.[0]?.lon;
966
1351
  if (lat !== undefined && lon !== undefined) {
967
- acc.push({
1352
+ const distM = calculateHaversineDistanceMeters(refPoint.lat, refPoint.lng, lat, lon);
1353
+ const el = {
968
1354
  id: e.id,
969
1355
  type: e.type,
970
1356
  name: e.tags?.name || "Unnamed",
971
1357
  coordinates: { lat, lon },
1358
+ distance_meters: Math.round(distM * 10) / 10,
972
1359
  tags: e.tags || {}
973
- });
1360
+ };
1361
+ if (returnGeometry && e.geometry) {
1362
+ el.geometry = e.geometry;
1363
+ }
1364
+ acc.push(el);
974
1365
  }
975
1366
  return acc;
976
1367
  }, []);
977
- const elementCount = parsedElements.length;
978
- let truncated = false;
979
- if (parsedElements.length > limit) {
980
- parsedElements = parsedElements.slice(0, limit);
981
- 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.`);
982
1383
  }
983
- const shareUrl = useCircle && centerPoint
984
- ? buildShareUrlCircleWithMarkers(centerPoint, radiusMeters, parsedElements)
985
- : buildShareUrlWithMarkers(bboxObj, parsedElements);
986
- 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') });
987
1393
  const responseData = {
988
- search_mode: useCircle ? 'circle' : 'bbox',
989
- ...(useCircle && centerPoint
990
- ? { center: centerPoint, radius_meters: radiusMeters }
991
- : { 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,
992
1398
  generator: data.generator,
993
1399
  osm3s: data.osm3s,
994
- elements: parsedElements,
995
- total_count: elementCount,
996
- returned_count: parsedElements.length,
1400
+ elements: pagedElements,
1401
+ total_count: totalCount,
1402
+ returned_count: pagedElements.length,
1403
+ offset,
1404
+ limit,
997
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.",
998
1411
  share_url: shareUrl
999
1412
  };
1000
- const topResults = parsedElements.slice(0, 5)
1001
- .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`)
1002
1415
  .join('\n');
1003
- 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)` : '';
1004
1417
  return {
1005
1418
  content: [
1006
1419
  {
1007
1420
  type: "text",
1008
- 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)}`
1009
1422
  },
1010
1423
  {
1011
1424
  type: "text",
@@ -1016,72 +1429,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1016
1429
  }
1017
1430
  else if (toolName === "list_osm_tags") {
1018
1431
  const args = request.params.arguments || {};
1019
- const category = (args.category || "").toLowerCase();
1432
+ const category = (args.category || "").toLowerCase().trim();
1020
1433
  let recommendations = "";
1021
1434
  switch (true) {
1022
- case category.includes('food') || category.includes('restaurant') || category.includes('cafe'):
1023
- 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"]`;
1024
1437
  break;
1025
- case category.includes('health') || category.includes('medical'):
1026
- 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"]`;
1027
1440
  break;
1028
- case category.includes('transport') || category.includes('transit') || category.includes('traffic'):
1029
- 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"]`;
1030
1443
  break;
1031
- 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'):
1032
1445
  recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
1033
1446
  break;
1034
- case category.includes('retail') || category.includes('shop') || category.includes('store'):
1035
- 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"]`;
1036
1449
  break;
1037
- case category.includes('education') || category.includes('school'):
1450
+ case category.includes('education') || category.includes('school') || category.includes('university') || category.includes('college') || category.includes('library'):
1038
1451
  recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
1039
1452
  break;
1040
- case category.includes('tourism') || category.includes('hotel'):
1041
- 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"]`;
1042
1485
  break;
1043
1486
  default:
1044
- 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);
1045
1489
  break;
1046
1490
  }
1047
1491
  return {
1048
1492
  content: [{
1049
1493
  type: "text",
1050
- 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.`
1051
1495
  }]
1052
1496
  };
1053
1497
  }
1054
1498
  else if (toolName === "aggregate_overpass_h3") {
1055
1499
  const args = request.params.arguments || {};
1056
1500
  const queryRaw = args.query;
1057
- 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 = [];
1058
1505
  if (!queryRaw) {
1059
1506
  return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
1060
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
+ }
1061
1511
  let bboxObj;
1512
+ let geocodeRes;
1513
+ let usedAreaId;
1062
1514
  if (args.location) {
1063
1515
  try {
1064
- 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
+ }
1065
1521
  }
1066
1522
  catch (err) {
1067
1523
  if (!args.bbox) {
1068
1524
  return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
1069
1525
  }
1070
- bboxObj = parseBBox(args.bbox);
1526
+ bboxObj = parseBBox(args.bbox, warnings);
1071
1527
  }
1072
1528
  }
1073
1529
  else if (args.bbox) {
1074
- bboxObj = parseBBox(args.bbox);
1530
+ bboxObj = parseBBox(args.bbox, warnings);
1075
1531
  }
1076
1532
  else {
1077
1533
  return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
1078
1534
  }
1079
- log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution });
1080
- // Ensure H3 resolution is valid early
1081
- if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
1082
- 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;`;
1083
1544
  }
1084
- const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
1085
1545
  let data = null;
1086
1546
  let lastError = null;
1087
1547
  const params = new URLSearchParams();
@@ -1093,9 +1553,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1093
1553
  if (process.env.OVERPASS_API_URL) {
1094
1554
  endpoints.unshift(process.env.OVERPASS_API_URL);
1095
1555
  }
1556
+ const clientTimeoutMs = (timeoutSec + 5) * 1000;
1096
1557
  for (const endpoint of endpoints) {
1097
1558
  const controller = new AbortController();
1098
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
1559
+ const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
1099
1560
  try {
1100
1561
  const response = await fetch(endpoint, {
1101
1562
  method: "POST",
@@ -1103,7 +1564,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1103
1564
  headers: {
1104
1565
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1105
1566
  'Accept': 'application/json',
1106
- 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
1567
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
1107
1568
  },
1108
1569
  signal: controller.signal
1109
1570
  });
@@ -1123,14 +1584,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1123
1584
  return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
1124
1585
  }
1125
1586
  let rawElements = data.elements || [];
1126
- // Bin into H3 Hexagons
1127
1587
  const hexCounts = {};
1128
1588
  const hexElements = {};
1129
1589
  const seenIds = new Set();
1130
1590
  rawElements.forEach((e) => {
1131
- if (seenIds.has(e.id))
1591
+ const dedupKey = `${e.type}/${e.id}`;
1592
+ if (seenIds.has(dedupKey))
1132
1593
  return;
1133
- seenIds.add(e.id);
1594
+ seenIds.add(dedupKey);
1134
1595
  const lat = e.lat ?? e.center?.lat;
1135
1596
  const lon = e.lon ?? e.center?.lon;
1136
1597
  if (lat !== undefined && lon !== undefined) {
@@ -1138,7 +1599,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1138
1599
  hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
1139
1600
  if (!hexElements[h3Index])
1140
1601
  hexElements[h3Index] = [];
1141
- // Keep element data minimal to avoid massive payloads
1142
1602
  hexElements[h3Index].push({
1143
1603
  id: e.id,
1144
1604
  name: e.tags?.name || "Unnamed"
@@ -1147,10 +1607,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1147
1607
  });
1148
1608
  const shareUrl = buildShareUrl(bboxObj);
1149
1609
  const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
1150
- // Generate GeoJSON of the active hexes
1151
1610
  const activeHexes = Object.keys(hexCounts);
1152
1611
  const geojson = h3CellsToGeoJSON(activeHexes);
1153
- // Inject the counts into the GeoJSON properties
1154
1612
  geojson.features.forEach((f) => {
1155
1613
  const cellId = f.properties.h3_index;
1156
1614
  f.properties.count = hexCounts[cellId];
@@ -1159,6 +1617,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1159
1617
  log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
1160
1618
  const responseData = {
1161
1619
  resolution,
1620
+ ...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
1621
+ ...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
1162
1622
  total_elements: totalGrouped,
1163
1623
  active_hex_count: activeHexes.length,
1164
1624
  hex_counts: hexCounts,
@@ -1166,7 +1626,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1166
1626
  original_bbox: bboxObj,
1167
1627
  share_url: shareUrl
1168
1628
  };
1169
- // Sort map for display
1170
1629
  const topHexes = Object.entries(hexCounts)
1171
1630
  .sort((a, b) => b[1] - a[1])
1172
1631
  .slice(0, 10);
@@ -1174,7 +1633,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1174
1633
  content: [
1175
1634
  {
1176
1635
  type: "text",
1177
- 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)}`
1178
1637
  },
1179
1638
  {
1180
1639
  type: "text",