bbox-mcp-server 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -320
- package/dist/index.js +619 -171
- 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})`);
|
|
592
|
+
}
|
|
593
|
+
const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
|
|
594
|
+
const url = `${NOMINATIM_BASE_URL}/reverse?format=json&lat=${lat}&lon=${lng}&zoom=${clampedZoom}&addressdetails=1`;
|
|
595
|
+
const data = await fetchNominatim(url);
|
|
596
|
+
if (!data || !data.display_name) {
|
|
597
|
+
throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
|
|
450
598
|
}
|
|
599
|
+
let bbox;
|
|
600
|
+
if (data.boundingbox && data.boundingbox.length === 4) {
|
|
601
|
+
const south = parseFloat(data.boundingbox[0]);
|
|
602
|
+
const north = parseFloat(data.boundingbox[1]);
|
|
603
|
+
const west = parseFloat(data.boundingbox[2]);
|
|
604
|
+
const east = parseFloat(data.boundingbox[3]);
|
|
605
|
+
bbox = ensureValidBBox(south, west, north, east);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
|
|
609
|
+
}
|
|
610
|
+
const shareUrl = buildShareUrl(bbox);
|
|
611
|
+
return {
|
|
612
|
+
matched_place: data.display_name,
|
|
613
|
+
center: { lat, lng },
|
|
614
|
+
bbox,
|
|
615
|
+
address: data.address || {},
|
|
616
|
+
class: data.class,
|
|
617
|
+
type: data.type,
|
|
618
|
+
osm_type: data.osm_type,
|
|
619
|
+
osm_id: data.osm_id ? parseInt(data.osm_id, 10) : undefined,
|
|
620
|
+
share_url: shareUrl
|
|
621
|
+
};
|
|
451
622
|
}
|
|
452
|
-
|
|
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
|
+
}
|
|
455
647
|
}
|
|
648
|
+
if (current.trim()) {
|
|
649
|
+
statements.push(current.trim());
|
|
650
|
+
}
|
|
651
|
+
return statements;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Attaches a spatial filter (e.g. `(area.searchArea)` or `(around:2000,lat,lng)`)
|
|
655
|
+
* to every selector statement in a multi-statement Overpass QL query string.
|
|
656
|
+
* Respects quoted semicolons in tag values and skips non-selector/output statements.
|
|
657
|
+
*/
|
|
658
|
+
function applySpatialFilter(queryRaw, spatialFilter) {
|
|
659
|
+
if (!spatialFilter)
|
|
660
|
+
return queryRaw.trim().endsWith(';') ? queryRaw.trim() : `${queryRaw.trim()};`;
|
|
661
|
+
const statements = splitOverpassStatements(queryRaw);
|
|
662
|
+
if (statements.length === 0)
|
|
663
|
+
return queryRaw;
|
|
664
|
+
const processed = statements.map(stmt => {
|
|
665
|
+
// Skip non-selector statements like 'out center', directives, set operations, comments
|
|
666
|
+
if (/^(out|[()\[]|->)/i.test(stmt) || /\bout\s+(body|skel|ids|meta|geom|center)/i.test(stmt)) {
|
|
667
|
+
return stmt;
|
|
668
|
+
}
|
|
669
|
+
if (stmt.includes(spatialFilter.trim())) {
|
|
670
|
+
return stmt;
|
|
671
|
+
}
|
|
672
|
+
// If statement defines a named set (e.g., `... -> .searchSet`), insert spatial filter before `->`
|
|
673
|
+
if (/->\s*\.[a-zA-Z0-9_]+/.test(stmt)) {
|
|
674
|
+
return stmt.replace(/(->\s*\.[a-zA-Z0-9_]+)/, `${spatialFilter} $1`);
|
|
675
|
+
}
|
|
676
|
+
return `${stmt}${spatialFilter}`;
|
|
677
|
+
});
|
|
678
|
+
return processed.join('; ') + ';';
|
|
679
|
+
}
|
|
680
|
+
function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
|
|
681
|
+
const R = 6371000; // Earth radius in meters
|
|
682
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
683
|
+
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
684
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
685
|
+
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
686
|
+
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
|
687
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
688
|
+
return R * c;
|
|
689
|
+
}
|
|
690
|
+
async function getTaginfoSuggestions(keyword) {
|
|
691
|
+
const url = `https://taginfo.openstreetmap.org/api/4/search/by_keyword?query=${encodeURIComponent(keyword)}&rp=30`;
|
|
456
692
|
const controller = new AbortController();
|
|
457
693
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
694
|
+
const MAINSTREAM_KEYS = new Set([
|
|
695
|
+
"amenity", "shop", "leisure", "tourism", "natural", "highway",
|
|
696
|
+
"office", "healthcare", "craft", "sport", "landuse", "building",
|
|
697
|
+
"historic", "emergency", "railway", "aeroway", "public_transport"
|
|
698
|
+
]);
|
|
458
699
|
try {
|
|
459
|
-
const
|
|
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,19 +881,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
610
881
|
},
|
|
611
882
|
query: {
|
|
612
883
|
type: "string",
|
|
613
|
-
description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. DO NOT include spatial filters
|
|
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."
|
|
622
909
|
},
|
|
623
910
|
return_geometry: {
|
|
624
911
|
type: "boolean",
|
|
625
|
-
description: "If true, returns
|
|
912
|
+
description: "If true, returns full geometry (polygon/polyline) for ways and relations instead of center point. Defaults to false."
|
|
626
913
|
}
|
|
627
914
|
},
|
|
628
915
|
required: ["query"],
|
|
@@ -634,13 +921,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
634
921
|
},
|
|
635
922
|
{
|
|
636
923
|
name: "list_osm_tags",
|
|
637
|
-
description: "Discovery tool to look up the correct OpenStreetMap tags for
|
|
924
|
+
description: "Discovery tool to look up the correct OpenStreetMap tags for any category. Uses built-in category index and live Taginfo API fallback to prevent tag hallucination.",
|
|
638
925
|
inputSchema: {
|
|
639
926
|
type: "object",
|
|
640
927
|
properties: {
|
|
641
928
|
category: {
|
|
642
929
|
type: "string",
|
|
643
|
-
description: "
|
|
930
|
+
description: "Category or keyword to look up (e.g. 'food', 'ev charging', 'gym', 'coworking', 'banks', 'dog park')."
|
|
644
931
|
}
|
|
645
932
|
},
|
|
646
933
|
required: ["category"]
|
|
@@ -667,6 +954,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
667
954
|
resolution: {
|
|
668
955
|
type: "number",
|
|
669
956
|
description: "The H3 resolution level (0-15) for binning. Default is 8."
|
|
957
|
+
},
|
|
958
|
+
timeout_seconds: {
|
|
959
|
+
type: "number",
|
|
960
|
+
description: "Overpass server query timeout in seconds. Default is 25."
|
|
961
|
+
},
|
|
962
|
+
use_area_boundary: {
|
|
963
|
+
type: "boolean",
|
|
964
|
+
description: "If true (default), uses exact OSM boundary polygon for named locations when available."
|
|
670
965
|
}
|
|
671
966
|
},
|
|
672
967
|
required: ["query", "resolution"],
|
|
@@ -685,23 +980,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
685
980
|
try {
|
|
686
981
|
if (toolName === "get_bounds") {
|
|
687
982
|
const args = request.params.arguments || {};
|
|
983
|
+
const warnings = [];
|
|
688
984
|
let bboxObj;
|
|
985
|
+
let geocodeRes;
|
|
689
986
|
if (args.location) {
|
|
690
987
|
try {
|
|
691
|
-
|
|
988
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
989
|
+
bboxObj = geocodeRes.bbox;
|
|
692
990
|
}
|
|
693
991
|
catch (err) {
|
|
694
992
|
if (!args.bbox) {
|
|
695
993
|
log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
|
|
696
994
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
697
995
|
}
|
|
698
|
-
// User provided bbox fallback
|
|
699
996
|
log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
|
|
700
|
-
bboxObj = parseBBox(args.bbox);
|
|
997
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
701
998
|
}
|
|
702
999
|
}
|
|
703
1000
|
else if (args.bbox) {
|
|
704
|
-
bboxObj = parseBBox(args.bbox);
|
|
1001
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
705
1002
|
}
|
|
706
1003
|
else {
|
|
707
1004
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
@@ -709,14 +1006,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
709
1006
|
let epsg = args.epsg || "4326";
|
|
710
1007
|
let format = args.format || "csv";
|
|
711
1008
|
let coordOrder = args.coord_order || "lng,lat";
|
|
712
|
-
let zoom = args.zoom
|
|
713
|
-
let precision = args.precision
|
|
1009
|
+
let zoom = args.zoom ?? 15;
|
|
1010
|
+
let precision = args.precision ?? 6;
|
|
714
1011
|
epsg = epsg.replace(/[^0-9]/g, "");
|
|
715
|
-
// Dynamically fetch projection if not bundled
|
|
716
1012
|
await ensureProjection(epsg);
|
|
717
1013
|
const projCoords = projectBounds(bboxObj, epsg);
|
|
718
1014
|
const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
|
|
719
|
-
// Build projected bbox for formatting (uses projected values)
|
|
720
1015
|
const projBbox = {
|
|
721
1016
|
lat1: projCoords.ymin,
|
|
722
1017
|
lng1: projCoords.xmin,
|
|
@@ -727,20 +1022,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
727
1022
|
const shareUrl = buildShareUrl(bboxObj);
|
|
728
1023
|
const dims = calculateBboxDimensions(bboxObj);
|
|
729
1024
|
const areaKm2 = calculateBboxArea(bboxObj);
|
|
730
|
-
// Calculate center and tile indices based on WGS84 original bounds
|
|
731
1025
|
const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
|
|
732
1026
|
const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
|
|
733
1027
|
const tileIndices = latLngToTile(centerLat, centerLng, zoom);
|
|
734
1028
|
log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
|
|
1029
|
+
const geocodeInfo = geocodeRes ? `Matched Place: "${geocodeRes.matched_place}"\n` : '';
|
|
1030
|
+
const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
|
|
1031
|
+
? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
|
|
1032
|
+
: '\n';
|
|
735
1033
|
return {
|
|
736
1034
|
content: [
|
|
737
1035
|
{
|
|
738
1036
|
type: "text",
|
|
739
|
-
text:
|
|
1037
|
+
text: `${geocodeInfo}${altInfo}Original WGS84 Bounds (${format}):\n${WGS84Formatted}\n\nProjected to EPSG:${epsg} (${format}):\n${projectedFormatted}\n\nArea: ${areaKm2.toFixed(3)} km² (${dims.widthKm.toFixed(3)}km x ${dims.heightKm.toFixed(3)}km)\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
740
1038
|
},
|
|
741
1039
|
{
|
|
742
1040
|
type: "text",
|
|
743
1041
|
text: JSON.stringify({
|
|
1042
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
744
1043
|
original_wgs84: bboxObj,
|
|
745
1044
|
projected: projCoords,
|
|
746
1045
|
center: {
|
|
@@ -767,35 +1066,64 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
767
1066
|
const args = request.params.arguments || {};
|
|
768
1067
|
const resolution = args.resolution;
|
|
769
1068
|
const compact = args.compact || false;
|
|
1069
|
+
const summaryOnly = args.summary_only || false;
|
|
770
1070
|
const returnGeometry = args.return_geometry || false;
|
|
1071
|
+
const warnings = [];
|
|
771
1072
|
if (resolution === undefined) {
|
|
772
1073
|
return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
|
|
773
1074
|
}
|
|
774
1075
|
let bboxObj;
|
|
1076
|
+
let geocodeRes;
|
|
775
1077
|
if (args.location) {
|
|
776
1078
|
try {
|
|
777
|
-
|
|
1079
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
1080
|
+
bboxObj = geocodeRes.bbox;
|
|
778
1081
|
}
|
|
779
1082
|
catch (err) {
|
|
780
1083
|
if (!args.bbox) {
|
|
781
1084
|
log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
|
|
782
1085
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
783
1086
|
}
|
|
784
|
-
bboxObj = parseBBox(args.bbox);
|
|
1087
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
785
1088
|
}
|
|
786
1089
|
}
|
|
787
1090
|
else if (args.bbox) {
|
|
788
|
-
bboxObj = parseBBox(args.bbox);
|
|
1091
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
789
1092
|
}
|
|
790
1093
|
else {
|
|
791
1094
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
792
1095
|
}
|
|
793
1096
|
const cells = getH3Cells(bboxObj, resolution, compact);
|
|
794
1097
|
const shareUrl = buildShareUrl(bboxObj);
|
|
795
|
-
log('info', `get_h3_indices completed`, { resolution, compact, cell_count: cells.length });
|
|
1098
|
+
log('info', `get_h3_indices completed`, { resolution, compact, summary_only: summaryOnly, cell_count: cells.length });
|
|
1099
|
+
if (summaryOnly) {
|
|
1100
|
+
const sampleCells = cells.slice(0, 10);
|
|
1101
|
+
const responseData = {
|
|
1102
|
+
resolution,
|
|
1103
|
+
compact,
|
|
1104
|
+
summary_only: true,
|
|
1105
|
+
cell_count: cells.length,
|
|
1106
|
+
sample_cells: sampleCells,
|
|
1107
|
+
original_bbox: bboxObj,
|
|
1108
|
+
share_url: shareUrl
|
|
1109
|
+
};
|
|
1110
|
+
return {
|
|
1111
|
+
content: [
|
|
1112
|
+
{
|
|
1113
|
+
type: "text",
|
|
1114
|
+
text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
type: "text",
|
|
1118
|
+
text: JSON.stringify(responseData, null, 2)
|
|
1119
|
+
}
|
|
1120
|
+
]
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
796
1123
|
const responseData = {
|
|
797
1124
|
resolution,
|
|
798
1125
|
compact,
|
|
1126
|
+
summary_only: false,
|
|
799
1127
|
cell_count: cells.length,
|
|
800
1128
|
cells,
|
|
801
1129
|
original_bbox: bboxObj,
|
|
@@ -808,7 +1136,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
808
1136
|
content: [
|
|
809
1137
|
{
|
|
810
1138
|
type: "text",
|
|
811
|
-
text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}`
|
|
1139
|
+
text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
812
1140
|
},
|
|
813
1141
|
{
|
|
814
1142
|
type: "text",
|
|
@@ -820,17 +1148,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
820
1148
|
else if (toolName === "generate_share_url") {
|
|
821
1149
|
const args = request.params.arguments || {};
|
|
822
1150
|
const bbox = args.bbox;
|
|
1151
|
+
const warnings = [];
|
|
823
1152
|
if (!bbox) {
|
|
824
1153
|
return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
|
|
825
1154
|
}
|
|
826
|
-
const bboxObj = parseBBox(bbox);
|
|
1155
|
+
const bboxObj = parseBBox(bbox, warnings);
|
|
827
1156
|
const shareUrl = buildShareUrl(bboxObj);
|
|
828
1157
|
log('info', `generate_share_url completed`, { share_url: shareUrl });
|
|
829
1158
|
return {
|
|
830
1159
|
content: [
|
|
831
1160
|
{
|
|
832
1161
|
type: "text",
|
|
833
|
-
text: `View on map: ${shareUrl}`
|
|
1162
|
+
text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
834
1163
|
},
|
|
835
1164
|
{
|
|
836
1165
|
type: "text",
|
|
@@ -842,10 +1171,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
842
1171
|
]
|
|
843
1172
|
};
|
|
844
1173
|
}
|
|
1174
|
+
else if (toolName === "reverse_geocode") {
|
|
1175
|
+
const args = request.params.arguments || {};
|
|
1176
|
+
const lat = args.lat;
|
|
1177
|
+
const lng = args.lng;
|
|
1178
|
+
const zoom = args.zoom ?? 18;
|
|
1179
|
+
if (lat === undefined || lng === undefined) {
|
|
1180
|
+
return { content: [{ type: "text", text: "Error: Both 'lat' and 'lng' arguments are required." }], isError: true };
|
|
1181
|
+
}
|
|
1182
|
+
const result = await reverseGeocode(lat, lng, zoom);
|
|
1183
|
+
log('info', `reverse_geocode completed`, { lat, lng, matched: result.matched_place });
|
|
1184
|
+
return {
|
|
1185
|
+
content: [
|
|
1186
|
+
{
|
|
1187
|
+
type: "text",
|
|
1188
|
+
text: `Matched Location: "${result.matched_place}"\nCoordinates: (${result.center.lat.toFixed(5)}, ${result.center.lng.toFixed(5)})\nOSM Details: [${result.osm_type || 'point'}] ${result.osm_id || 'N/A'}\n\nView on map: ${result.share_url}`
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
type: "text",
|
|
1192
|
+
text: JSON.stringify(result, null, 2)
|
|
1193
|
+
}
|
|
1194
|
+
]
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
845
1197
|
else if (toolName === "search_overpass") {
|
|
846
1198
|
const args = request.params.arguments || {};
|
|
847
1199
|
const queryRaw = args.query;
|
|
848
1200
|
const limit = args.limit || 100;
|
|
1201
|
+
const offset = args.offset || 0;
|
|
1202
|
+
const timeoutSec = args.timeout_seconds || 25;
|
|
1203
|
+
const useAreaBoundary = args.use_area_boundary !== false;
|
|
849
1204
|
const radiusMeters = args.radius_meters;
|
|
850
1205
|
const returnGeometry = args.return_geometry || false;
|
|
851
1206
|
const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
|
|
@@ -858,20 +1213,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
858
1213
|
if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
|
|
859
1214
|
return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
|
|
860
1215
|
}
|
|
1216
|
+
const warnings = [];
|
|
861
1217
|
let overpassQuery;
|
|
862
1218
|
let searchModeLabel;
|
|
863
|
-
let
|
|
1219
|
+
let refPoint;
|
|
864
1220
|
let bboxObj;
|
|
1221
|
+
let geocodeRes;
|
|
1222
|
+
let usedAreaId;
|
|
865
1223
|
if (useCircle) {
|
|
866
1224
|
if (args.location) {
|
|
867
1225
|
try {
|
|
868
|
-
|
|
1226
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
1227
|
+
refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded center of "${geocodeRes.matched_place}"` };
|
|
869
1228
|
}
|
|
870
1229
|
catch (err) {
|
|
871
1230
|
if (args.bbox) {
|
|
872
1231
|
log('warn', `Geocoding failed for radius search, using bbox center`, { error: err.message });
|
|
873
|
-
const fb = parseBBox(args.bbox);
|
|
874
|
-
|
|
1232
|
+
const fb = parseBBox(args.bbox, warnings);
|
|
1233
|
+
refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
|
|
875
1234
|
}
|
|
876
1235
|
else {
|
|
877
1236
|
log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
|
|
@@ -880,25 +1239,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
880
1239
|
}
|
|
881
1240
|
}
|
|
882
1241
|
else if (args.bbox) {
|
|
883
|
-
const fb = parseBBox(args.bbox);
|
|
884
|
-
|
|
1242
|
+
const fb = parseBBox(args.bbox, warnings);
|
|
1243
|
+
refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
|
|
885
1244
|
}
|
|
886
1245
|
else {
|
|
887
1246
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
888
1247
|
}
|
|
889
1248
|
const outDirective = returnGeometry ? "out geom;" : "out center;";
|
|
890
|
-
|
|
891
|
-
|
|
1249
|
+
const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
|
|
1250
|
+
const formattedCore = applySpatialFilter(queryRaw, circleFilter);
|
|
1251
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
|
|
1252
|
+
searchModeLabel = `circle — ${radiusMeters}m radius around (${refPoint.lat.toFixed(5)}, ${refPoint.lng.toFixed(5)})`;
|
|
892
1253
|
}
|
|
893
1254
|
else {
|
|
894
1255
|
if (args.location) {
|
|
895
1256
|
try {
|
|
896
|
-
|
|
1257
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
1258
|
+
bboxObj = geocodeRes.bbox;
|
|
1259
|
+
refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded centroid of "${geocodeRes.matched_place}"` };
|
|
1260
|
+
if (useAreaBoundary && geocodeRes.osm_area_id) {
|
|
1261
|
+
usedAreaId = geocodeRes.osm_area_id;
|
|
1262
|
+
}
|
|
897
1263
|
}
|
|
898
1264
|
catch (err) {
|
|
899
1265
|
if (args.bbox) {
|
|
900
1266
|
log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
|
|
901
|
-
bboxObj = parseBBox(args.bbox);
|
|
1267
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1268
|
+
refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
|
|
902
1269
|
}
|
|
903
1270
|
else {
|
|
904
1271
|
log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
|
|
@@ -907,16 +1274,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
907
1274
|
}
|
|
908
1275
|
}
|
|
909
1276
|
else if (args.bbox) {
|
|
910
|
-
bboxObj = parseBBox(args.bbox);
|
|
1277
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1278
|
+
refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
|
|
911
1279
|
}
|
|
912
1280
|
else {
|
|
913
1281
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
914
1282
|
}
|
|
915
1283
|
const outDirective = returnGeometry ? "out geom;" : "out center;";
|
|
916
|
-
|
|
917
|
-
|
|
1284
|
+
if (usedAreaId) {
|
|
1285
|
+
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
1286
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
|
|
1287
|
+
searchModeLabel = `exact OSM boundary area (id: ${usedAreaId}) for "${geocodeRes?.matched_place}"`;
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
const formattedCore = applySpatialFilter(queryRaw, "");
|
|
1291
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\n${outDirective}`;
|
|
1292
|
+
searchModeLabel = `bounding box`;
|
|
1293
|
+
}
|
|
918
1294
|
}
|
|
919
|
-
log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : 'bbox', radius_meters: radiusMeters });
|
|
1295
|
+
log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, timeoutSec });
|
|
920
1296
|
const endpoints = [
|
|
921
1297
|
"https://overpass-api.de/api/interpreter",
|
|
922
1298
|
"https://overpass.kumi.systems/api/interpreter"
|
|
@@ -928,9 +1304,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
928
1304
|
let lastError = null;
|
|
929
1305
|
const params = new URLSearchParams();
|
|
930
1306
|
params.append('data', overpassQuery);
|
|
1307
|
+
const clientTimeoutMs = (timeoutSec + 5) * 1000;
|
|
931
1308
|
for (const endpoint of endpoints) {
|
|
932
1309
|
const controller = new AbortController();
|
|
933
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
1310
|
+
const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
|
|
934
1311
|
try {
|
|
935
1312
|
log('info', `Executing Overpass query at ${endpoint}`);
|
|
936
1313
|
const response = await fetch(endpoint, {
|
|
@@ -939,7 +1316,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
939
1316
|
headers: {
|
|
940
1317
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
941
1318
|
'Accept': 'application/json',
|
|
942
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
|
|
1319
|
+
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
|
|
943
1320
|
},
|
|
944
1321
|
signal: controller.signal
|
|
945
1322
|
});
|
|
@@ -959,23 +1336,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
959
1336
|
}
|
|
960
1337
|
if (!data) {
|
|
961
1338
|
if (lastError?.name === 'AbortError') {
|
|
962
|
-
return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${
|
|
1339
|
+
return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${clientTimeoutMs / 1000}s. The query or area may be too large.` }], isError: true };
|
|
963
1340
|
}
|
|
964
1341
|
throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
|
|
965
1342
|
}
|
|
966
1343
|
const seenIds = new Set();
|
|
967
1344
|
let parsedElements = (data.elements || []).reduce((acc, e) => {
|
|
968
|
-
|
|
1345
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
1346
|
+
if (seenIds.has(dedupKey))
|
|
969
1347
|
return acc;
|
|
970
|
-
seenIds.add(
|
|
1348
|
+
seenIds.add(dedupKey);
|
|
971
1349
|
const lat = e.lat ?? e.center?.lat ?? e.geometry?.[0]?.lat;
|
|
972
1350
|
const lon = e.lon ?? e.center?.lon ?? e.geometry?.[0]?.lon;
|
|
973
1351
|
if (lat !== undefined && lon !== undefined) {
|
|
1352
|
+
const distM = calculateHaversineDistanceMeters(refPoint.lat, refPoint.lng, lat, lon);
|
|
974
1353
|
const el = {
|
|
975
1354
|
id: e.id,
|
|
976
1355
|
type: e.type,
|
|
977
1356
|
name: e.tags?.name || "Unnamed",
|
|
978
1357
|
coordinates: { lat, lon },
|
|
1358
|
+
distance_meters: Math.round(distM * 10) / 10,
|
|
979
1359
|
tags: e.tags || {}
|
|
980
1360
|
};
|
|
981
1361
|
if (returnGeometry && e.geometry) {
|
|
@@ -985,38 +1365,60 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
985
1365
|
}
|
|
986
1366
|
return acc;
|
|
987
1367
|
}, []);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1368
|
+
// Deterministic sorting: sort by (distance_meters, type, id) ascending
|
|
1369
|
+
const sortByDistance = args.sort_by_distance !== false;
|
|
1370
|
+
parsedElements.sort((a, b) => {
|
|
1371
|
+
if (sortByDistance) {
|
|
1372
|
+
const diff = (a.distance_meters ?? 0) - (b.distance_meters ?? 0);
|
|
1373
|
+
if (Math.abs(diff) > 0.001)
|
|
1374
|
+
return diff;
|
|
1375
|
+
}
|
|
1376
|
+
const typeDiff = (a.type || "").localeCompare(b.type || "");
|
|
1377
|
+
if (typeDiff !== 0)
|
|
1378
|
+
return typeDiff;
|
|
1379
|
+
return (a.id ?? 0) - (b.id ?? 0);
|
|
1380
|
+
});
|
|
1381
|
+
if (args.location && useAreaBoundary && !usedAreaId && geocodeRes) {
|
|
1382
|
+
warnings.push(`Exact admin boundary polygon unavailable for '${geocodeRes.matched_place}', fell back to bounding box query.`);
|
|
993
1383
|
}
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1384
|
+
const totalCount = parsedElements.length;
|
|
1385
|
+
const pagedElements = parsedElements.slice(offset, offset + limit);
|
|
1386
|
+
const truncated = totalCount > offset + limit;
|
|
1387
|
+
const hasMore = totalCount > offset + limit;
|
|
1388
|
+
const nextOffset = hasMore ? offset + limit : undefined;
|
|
1389
|
+
const shareUrl = useCircle
|
|
1390
|
+
? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
|
|
1391
|
+
: buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements);
|
|
1392
|
+
log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
|
|
998
1393
|
const responseData = {
|
|
999
|
-
search_mode: useCircle ? 'circle' : 'bbox',
|
|
1000
|
-
...(
|
|
1001
|
-
|
|
1002
|
-
|
|
1394
|
+
search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
|
|
1395
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
1396
|
+
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
1397
|
+
distance_from: refPoint,
|
|
1003
1398
|
generator: data.generator,
|
|
1004
1399
|
osm3s: data.osm3s,
|
|
1005
|
-
elements:
|
|
1006
|
-
total_count:
|
|
1007
|
-
returned_count:
|
|
1400
|
+
elements: pagedElements,
|
|
1401
|
+
total_count: totalCount,
|
|
1402
|
+
returned_count: pagedElements.length,
|
|
1403
|
+
offset,
|
|
1404
|
+
limit,
|
|
1008
1405
|
truncated,
|
|
1406
|
+
has_more: hasMore,
|
|
1407
|
+
...(nextOffset !== undefined ? { next_offset: nextOffset } : {}),
|
|
1408
|
+
pagination_hint: hasMore
|
|
1409
|
+
? `Use offset=${nextOffset} to fetch the next page. Note: Each page re-queries Overpass; for large result sets, consider narrowing your query filters or bounding area instead of deep paging.`
|
|
1410
|
+
: "End of result set.",
|
|
1009
1411
|
share_url: shareUrl
|
|
1010
1412
|
};
|
|
1011
|
-
const topResults =
|
|
1012
|
-
.map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)})`)
|
|
1413
|
+
const topResults = pagedElements.slice(0, 5)
|
|
1414
|
+
.map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
|
|
1013
1415
|
.join('\n');
|
|
1014
|
-
const moreNote =
|
|
1416
|
+
const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page (see JSON)` : '';
|
|
1015
1417
|
return {
|
|
1016
1418
|
content: [
|
|
1017
1419
|
{
|
|
1018
1420
|
type: "text",
|
|
1019
|
-
text: `Overpass query executed within a ${searchModeLabel}.\nFound ${
|
|
1421
|
+
text: `Overpass query executed within a ${searchModeLabel}.\nFound ${totalCount} element${totalCount !== 1 ? 's' : ''}${totalCount > limit ? ` (showing items ${offset + 1}-${offset + pagedElements.length} of ${totalCount})` : ''}.\nDistance measured from: ${refPoint.description}\n\nTop results:\n${topResults}${moreNote}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1020
1422
|
},
|
|
1021
1423
|
{
|
|
1022
1424
|
type: "text",
|
|
@@ -1027,72 +1429,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1027
1429
|
}
|
|
1028
1430
|
else if (toolName === "list_osm_tags") {
|
|
1029
1431
|
const args = request.params.arguments || {};
|
|
1030
|
-
const category = (args.category || "").toLowerCase();
|
|
1432
|
+
const category = (args.category || "").toLowerCase().trim();
|
|
1031
1433
|
let recommendations = "";
|
|
1032
1434
|
switch (true) {
|
|
1033
|
-
case category.includes('food') || category.includes('restaurant') || category.includes('cafe'):
|
|
1034
|
-
recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]`;
|
|
1435
|
+
case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
|
|
1436
|
+
recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]\nIce Cream: nwr["amenity"="ice_cream"]\nCoffee Shops: nwr["shop"="coffee"]\nBakeries: nwr["shop"="bakery"]`;
|
|
1035
1437
|
break;
|
|
1036
|
-
case category.includes('health') || category.includes('medical'):
|
|
1037
|
-
recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]`;
|
|
1438
|
+
case category.includes('health') || category.includes('medical') || category.includes('doctor') || category.includes('hospital') || category.includes('pharmacy'):
|
|
1439
|
+
recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]\nGeneral Healthcare: nwr["healthcare"]`;
|
|
1038
1440
|
break;
|
|
1039
|
-
case category.includes('transport') || category.includes('transit') || category.includes('
|
|
1040
|
-
recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]`;
|
|
1441
|
+
case category.includes('transport') || category.includes('transit') || category.includes('bus') || category.includes('train') || category.includes('subway') || category.includes('gas') || category.includes('fuel') || category.includes('parking') || category.includes('airport') || category.includes('taxi'):
|
|
1442
|
+
recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]\nFerry Terminals: nwr["amenity"="ferry_terminal"]\nAerodromes: nwr["aeroway"="aerodrome"]\nTaxi Stands: nwr["amenity"="taxi"]`;
|
|
1041
1443
|
break;
|
|
1042
|
-
case category.includes('leisure') || category.includes('park') || category.includes('
|
|
1444
|
+
case category.includes('leisure') || category.includes('park') || category.includes('playground') || category.includes('theatre') || category.includes('cinema'):
|
|
1043
1445
|
recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
|
|
1044
1446
|
break;
|
|
1045
|
-
case category.includes('retail') || category.includes('shop') || category.includes('store'):
|
|
1046
|
-
recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]`;
|
|
1447
|
+
case category.includes('retail') || category.includes('shop') || category.includes('store') || category.includes('supermarket') || category.includes('mall'):
|
|
1448
|
+
recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]\nAlcohol Shops: nwr["shop"="alcohol"]\nHairdressers: nwr["shop"="hairdresser"]\nLaundries: nwr["shop"="laundry"]`;
|
|
1047
1449
|
break;
|
|
1048
|
-
case category.includes('education') || category.includes('school'):
|
|
1450
|
+
case category.includes('education') || category.includes('school') || category.includes('university') || category.includes('college') || category.includes('library'):
|
|
1049
1451
|
recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
|
|
1050
1452
|
break;
|
|
1051
|
-
case category.includes('tourism') || category.includes('hotel'):
|
|
1052
|
-
recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\
|
|
1453
|
+
case category.includes('tourism') || category.includes('hotel') || category.includes('motel') || category.includes('hostel') || category.includes('museum') || category.includes('attraction'):
|
|
1454
|
+
recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\nGuest Houses: nwr["tourism"="guest_house"]\nApartments: nwr["tourism"="apartment"]\nCampsites: nwr["tourism"="camp_site"]`;
|
|
1455
|
+
break;
|
|
1456
|
+
case category.includes('finance') || category.includes('bank') || category.includes('atm') || category.includes('post') || category.includes('police') || category.includes('fire') || category.includes('embassy'):
|
|
1457
|
+
recommendations = `Banks: nwr["amenity"="bank"]\nATMs: nwr["amenity"="atm"]\nPost Offices: nwr["amenity"="post_office"]\nPolice: nwr["amenity"="police"]\nFire Stations: nwr["amenity"="fire_station"]\nCourthouses: nwr["amenity"="courthouse"]\nEmbassies: nwr["office"="diplomatic"]`;
|
|
1458
|
+
break;
|
|
1459
|
+
case category.includes('fitness') || category.includes('sport') || category.includes('gym') || category.includes('swimming') || category.includes('pool') || category.includes('stadium') || category.includes('golf') || category.includes('climbing'):
|
|
1460
|
+
recommendations = `Gyms: nwr["leisure"="fitness_centre"]\nSwimming Pools: nwr["leisure"="swimming_pool"]\nStadiums: nwr["leisure"="stadium"]\nSports Pitches: nwr["leisure"="pitch"]\nGolf Courses: nwr["leisure"="golf_course"]\nClimbing: nwr["sport"="climbing"]`;
|
|
1461
|
+
break;
|
|
1462
|
+
case category.includes('nature') || category.includes('outdoor') || category.includes('forest') || category.includes('water') || category.includes('beach') || category.includes('peak') || category.includes('viewpoint') || category.includes('hike') || category.includes('trail'):
|
|
1463
|
+
recommendations = `Forests: nwr["landuse"="forest"]\nWater Bodies: nwr["natural"="water"]\nBeaches: nwr["natural"="beach"]\nPeaks: nwr["natural"="peak"]\nViewpoints: nwr["tourism"="viewpoint"]\nNature Reserves: nwr["leisure"="nature_reserve"]\nPicnic Sites: nwr["tourism"="picnic_site"]\nHiking Trails: way["highway"="path"]["sac_scale"]`;
|
|
1464
|
+
break;
|
|
1465
|
+
case category.includes('religion') || category.includes('worship') || category.includes('church') || category.includes('mosque') || category.includes('synagogue') || category.includes('temple') || category.includes('cemetery'):
|
|
1466
|
+
recommendations = `Places of Worship: nwr["amenity"="place_of_worship"]\nChurches: nwr["amenity"="place_of_worship"]["religion"="christian"]\nMosques: nwr["amenity"="place_of_worship"]["religion"="muslim"]\nSynagogues: nwr["amenity"="place_of_worship"]["religion"="jewish"]\nTemples: nwr["amenity"="place_of_worship"]["religion"~"buddhist|hindu"]\nCemeteries: nwr["landuse"="cemetery"]`;
|
|
1467
|
+
break;
|
|
1468
|
+
case category.includes('ev') || category.includes('automotive') || category.includes('car') || category.includes('charging') || category.includes('mechanic') || category.includes('rental'):
|
|
1469
|
+
recommendations = `EV Charging: nwr["amenity"="charging_station"]\nCar Repair: nwr["shop"="car_repair"]\nCar Dealers: nwr["shop"="car"]\nCar Wash: nwr["amenity"="car_wash"]\nCar Rental: nwr["amenity"="car_rental"]\nDriving Schools: nwr["amenity"="driving_school"]`;
|
|
1470
|
+
break;
|
|
1471
|
+
case category.includes('nightlife') || category.includes('nightclub') || category.includes('casino') || category.includes('arcade') || category.includes('bowling') || category.includes('escape'):
|
|
1472
|
+
recommendations = `Nightclubs: nwr["amenity"="nightclub"]\nCasinos: nwr["amenity"="casino"]\nArcades: nwr["leisure"="amusement_arcade"]\nBowling Alleys: nwr["leisure"="bowling_alley"]\nEscape Rooms: nwr["leisure"="escape_game"]`;
|
|
1473
|
+
break;
|
|
1474
|
+
case category.includes('utility') || category.includes('toilet') || category.includes('water') || category.includes('bench') || category.includes('waste') || category.includes('recycling') || category.includes('shower') || category.includes('locker'):
|
|
1475
|
+
recommendations = `Toilets: nwr["amenity"="toilets"]\nDrinking Water: nwr["amenity"="drinking_water"]\nBenches: nwr["amenity"="bench"]\nWaste Baskets: nwr["amenity"="waste_basket"]\nRecycling: nwr["amenity"="recycling"]\nPublic Showers: nwr["amenity"="shower"]\nLockers: nwr["amenity"="locker"]`;
|
|
1476
|
+
break;
|
|
1477
|
+
case category.includes('work') || category.includes('office') || category.includes('coworking') || category.includes('conference'):
|
|
1478
|
+
recommendations = `Coworking Spaces: nwr["office"="coworking"]; nwr["amenity"="coworking_space"];\nGeneral Offices: nwr["office"]\nConference Centres: nwr["amenity"="conference_centre"]`;
|
|
1479
|
+
break;
|
|
1480
|
+
case category.includes('pet') || category.includes('dog') || category.includes('vet') || category.includes('zoo') || category.includes('animal'):
|
|
1481
|
+
recommendations = `Dog Parks: nwr["leisure"="dog_park"]\nVeterinarians: nwr["amenity"="veterinary"]\nPet Shops: nwr["shop"="pet"]\nZoos: nwr["tourism"="zoo"]`;
|
|
1482
|
+
break;
|
|
1483
|
+
case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
|
|
1484
|
+
recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
|
|
1053
1485
|
break;
|
|
1054
1486
|
default:
|
|
1055
|
-
|
|
1487
|
+
log('info', `Category '${category}' not in index, querying Taginfo API`);
|
|
1488
|
+
recommendations = await getTaginfoSuggestions(category);
|
|
1056
1489
|
break;
|
|
1057
1490
|
}
|
|
1058
1491
|
return {
|
|
1059
1492
|
content: [{
|
|
1060
1493
|
type: "text",
|
|
1061
|
-
text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can
|
|
1494
|
+
text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can use these tag selectors directly in the 'query' argument of search_overpass.`
|
|
1062
1495
|
}]
|
|
1063
1496
|
};
|
|
1064
1497
|
}
|
|
1065
1498
|
else if (toolName === "aggregate_overpass_h3") {
|
|
1066
1499
|
const args = request.params.arguments || {};
|
|
1067
1500
|
const queryRaw = args.query;
|
|
1068
|
-
const resolution = args.resolution
|
|
1501
|
+
const resolution = args.resolution ?? 8;
|
|
1502
|
+
const timeoutSec = args.timeout_seconds || 25;
|
|
1503
|
+
const useAreaBoundary = args.use_area_boundary !== false;
|
|
1504
|
+
const warnings = [];
|
|
1069
1505
|
if (!queryRaw) {
|
|
1070
1506
|
return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
|
|
1071
1507
|
}
|
|
1508
|
+
if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
|
|
1509
|
+
return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
|
|
1510
|
+
}
|
|
1072
1511
|
let bboxObj;
|
|
1512
|
+
let geocodeRes;
|
|
1513
|
+
let usedAreaId;
|
|
1073
1514
|
if (args.location) {
|
|
1074
1515
|
try {
|
|
1075
|
-
|
|
1516
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
1517
|
+
bboxObj = geocodeRes.bbox;
|
|
1518
|
+
if (useAreaBoundary && geocodeRes.osm_area_id) {
|
|
1519
|
+
usedAreaId = geocodeRes.osm_area_id;
|
|
1520
|
+
}
|
|
1076
1521
|
}
|
|
1077
1522
|
catch (err) {
|
|
1078
1523
|
if (!args.bbox) {
|
|
1079
1524
|
return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
|
|
1080
1525
|
}
|
|
1081
|
-
bboxObj = parseBBox(args.bbox);
|
|
1526
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1082
1527
|
}
|
|
1083
1528
|
}
|
|
1084
1529
|
else if (args.bbox) {
|
|
1085
|
-
bboxObj = parseBBox(args.bbox);
|
|
1530
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1086
1531
|
}
|
|
1087
1532
|
else {
|
|
1088
1533
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1089
1534
|
}
|
|
1090
|
-
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution });
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1535
|
+
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: usedAreaId ? 'area' : 'bbox' });
|
|
1536
|
+
let overpassQuery;
|
|
1537
|
+
if (usedAreaId) {
|
|
1538
|
+
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
1539
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
const formattedCore = applySpatialFilter(queryRaw, "");
|
|
1543
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
|
|
1094
1544
|
}
|
|
1095
|
-
const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
|
|
1096
1545
|
let data = null;
|
|
1097
1546
|
let lastError = null;
|
|
1098
1547
|
const params = new URLSearchParams();
|
|
@@ -1104,9 +1553,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1104
1553
|
if (process.env.OVERPASS_API_URL) {
|
|
1105
1554
|
endpoints.unshift(process.env.OVERPASS_API_URL);
|
|
1106
1555
|
}
|
|
1556
|
+
const clientTimeoutMs = (timeoutSec + 5) * 1000;
|
|
1107
1557
|
for (const endpoint of endpoints) {
|
|
1108
1558
|
const controller = new AbortController();
|
|
1109
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
1559
|
+
const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
|
|
1110
1560
|
try {
|
|
1111
1561
|
const response = await fetch(endpoint, {
|
|
1112
1562
|
method: "POST",
|
|
@@ -1114,7 +1564,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1114
1564
|
headers: {
|
|
1115
1565
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
1116
1566
|
'Accept': 'application/json',
|
|
1117
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
|
|
1567
|
+
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
|
|
1118
1568
|
},
|
|
1119
1569
|
signal: controller.signal
|
|
1120
1570
|
});
|
|
@@ -1134,14 +1584,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1134
1584
|
return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
|
|
1135
1585
|
}
|
|
1136
1586
|
let rawElements = data.elements || [];
|
|
1137
|
-
// Bin into H3 Hexagons
|
|
1138
1587
|
const hexCounts = {};
|
|
1139
1588
|
const hexElements = {};
|
|
1140
1589
|
const seenIds = new Set();
|
|
1141
1590
|
rawElements.forEach((e) => {
|
|
1142
|
-
|
|
1591
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
1592
|
+
if (seenIds.has(dedupKey))
|
|
1143
1593
|
return;
|
|
1144
|
-
seenIds.add(
|
|
1594
|
+
seenIds.add(dedupKey);
|
|
1145
1595
|
const lat = e.lat ?? e.center?.lat;
|
|
1146
1596
|
const lon = e.lon ?? e.center?.lon;
|
|
1147
1597
|
if (lat !== undefined && lon !== undefined) {
|
|
@@ -1149,7 +1599,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1149
1599
|
hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
|
|
1150
1600
|
if (!hexElements[h3Index])
|
|
1151
1601
|
hexElements[h3Index] = [];
|
|
1152
|
-
// Keep element data minimal to avoid massive payloads
|
|
1153
1602
|
hexElements[h3Index].push({
|
|
1154
1603
|
id: e.id,
|
|
1155
1604
|
name: e.tags?.name || "Unnamed"
|
|
@@ -1158,10 +1607,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1158
1607
|
});
|
|
1159
1608
|
const shareUrl = buildShareUrl(bboxObj);
|
|
1160
1609
|
const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
|
|
1161
|
-
// Generate GeoJSON of the active hexes
|
|
1162
1610
|
const activeHexes = Object.keys(hexCounts);
|
|
1163
1611
|
const geojson = h3CellsToGeoJSON(activeHexes);
|
|
1164
|
-
// Inject the counts into the GeoJSON properties
|
|
1165
1612
|
geojson.features.forEach((f) => {
|
|
1166
1613
|
const cellId = f.properties.h3_index;
|
|
1167
1614
|
f.properties.count = hexCounts[cellId];
|
|
@@ -1170,6 +1617,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1170
1617
|
log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
|
|
1171
1618
|
const responseData = {
|
|
1172
1619
|
resolution,
|
|
1620
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
1621
|
+
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
1173
1622
|
total_elements: totalGrouped,
|
|
1174
1623
|
active_hex_count: activeHexes.length,
|
|
1175
1624
|
hex_counts: hexCounts,
|
|
@@ -1177,7 +1626,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1177
1626
|
original_bbox: bboxObj,
|
|
1178
1627
|
share_url: shareUrl
|
|
1179
1628
|
};
|
|
1180
|
-
// Sort map for display
|
|
1181
1629
|
const topHexes = Object.entries(hexCounts)
|
|
1182
1630
|
.sort((a, b) => b[1] - a[1])
|
|
1183
1631
|
.slice(0, 10);
|
|
@@ -1185,7 +1633,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1185
1633
|
content: [
|
|
1186
1634
|
{
|
|
1187
1635
|
type: "text",
|
|
1188
|
-
text: `Aggregated ${totalGrouped} element${totalGrouped !== 1 ? 's' : ''} into ${activeHexes.length} H3 hexagon${activeHexes.length !== 1 ? 's' : ''} at resolution ${resolution}.\n\nTop hexagons by density:\n${topHexes.map(([hex, count]) => `- ${hex}: ${count} element${count !== 1 ? 's' : ''}`).join('\n')}\n\nView on map: ${shareUrl}`
|
|
1636
|
+
text: `Aggregated ${totalGrouped} element${totalGrouped !== 1 ? 's' : ''} into ${activeHexes.length} H3 hexagon${activeHexes.length !== 1 ? 's' : ''} at resolution ${resolution}.\n\nTop hexagons by density:\n${topHexes.map(([hex, count]) => `- ${hex}: ${count} element${count !== 1 ? 's' : ''}`).join('\n')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1189
1637
|
},
|
|
1190
1638
|
{
|
|
1191
1639
|
type: "text",
|