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/README.md +322 -320
- package/dist/index.js +633 -174
- package/dist/package.json +70 -0
- package/package.json +1 -1
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 =
|
|
11
|
-
const MAX_H3_CELLS =
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
//
|
|
178
|
-
//
|
|
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
|
-
|
|
204
|
-
const
|
|
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:
|
|
207
|
-
ymin:
|
|
208
|
-
xmax:
|
|
209
|
-
ymax:
|
|
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 =
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
405
|
-
|
|
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(`
|
|
463
|
+
throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
|
|
408
464
|
}
|
|
409
465
|
const data = await response.json();
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
lat2: feature.bbox[3],
|
|
423
|
-
lng2: feature.bbox[2]
|
|
527
|
+
...primary,
|
|
528
|
+
alternatives
|
|
424
529
|
};
|
|
425
530
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
lat2: centerLat + offset,
|
|
435
|
-
lng2: centerLng + offset
|
|
573
|
+
...primary,
|
|
574
|
+
alternatives
|
|
436
575
|
};
|
|
437
576
|
}
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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').
|
|
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
|
|
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. '
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
|
709
|
-
let precision = args.precision
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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(),
|
|
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 ${
|
|
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
|
-
|
|
1345
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
1346
|
+
if (seenIds.has(dedupKey))
|
|
962
1347
|
return acc;
|
|
963
|
-
seenIds.add(
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
...(
|
|
990
|
-
|
|
991
|
-
|
|
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:
|
|
995
|
-
total_count:
|
|
996
|
-
returned_count:
|
|
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 =
|
|
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 =
|
|
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 ${
|
|
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('
|
|
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('
|
|
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"]\
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
-
if (
|
|
1082
|
-
|
|
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(),
|
|
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
|
-
|
|
1591
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
1592
|
+
if (seenIds.has(dedupKey))
|
|
1132
1593
|
return;
|
|
1133
|
-
seenIds.add(
|
|
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",
|