bbox-mcp-server 1.2.4 → 1.2.5

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +145 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ The geospatial toolkit for AI agents. **6 tools, zero config** — give any LLM
4
4
 
5
5
  Every response includes a verification link to **[vibhorsingh.com/boundingbox](https://vibhorsingh.com/boundingbox/)** so you can visually confirm results on an interactive map.
6
6
 
7
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
7
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) [![MCP Badge](https://lobehub.com/badge/mcp/iamvibhorsingh-bbox-mcp-server)](https://lobehub.com/mcp/iamvibhorsingh-bbox-mcp-server)
8
8
 
9
9
  ---
10
10
 
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import proj4 from "proj4";
7
7
  import proj4defs from "./proj4defs.json" with { type: "json" };
8
8
  import { wktToGeoJSON as parseWKT } from "@terraformer/wkt";
9
9
  const SERVER_NAME = "bbox-mcp-server";
10
- const SERVER_VERSION = "1.2.4";
10
+ const SERVER_VERSION = "1.2.5";
11
11
  const MAX_H3_CELLS = process.env.MAX_H3_CELLS ? parseInt(process.env.MAX_H3_CELLS, 10) : 50000;
12
12
  const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN;
13
13
  const MAX_INPUT_LENGTH = 1_000_000; // 1 MB input size guard
@@ -322,6 +322,21 @@ function buildShareUrl(bbox) {
322
322
  const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
323
323
  return `https://vibhorsingh.com/boundingbox/#${hash}`;
324
324
  }
325
+ function buildShareUrlWithMarkers(bbox, elements) {
326
+ const bboxPart = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
327
+ const MAX_MARKERS = 30;
328
+ const markerParts = elements
329
+ .filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
330
+ .slice(0, MAX_MARKERS)
331
+ .map(e => {
332
+ let name = e.name || 'Point';
333
+ if (name.length > 30)
334
+ name = name.slice(0, 27) + '...';
335
+ return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
336
+ });
337
+ const hashParts = [bboxPart, ...markerParts];
338
+ return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
339
+ }
325
340
  function getH3Cells(bbox, resolution, compact) {
326
341
  if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
327
342
  throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
@@ -434,6 +449,54 @@ async function geocodeLocation(query) {
434
449
  clearTimeout(timeout);
435
450
  }
436
451
  }
452
+ async function geocodeLocationPoint(query) {
453
+ if (!MAPBOX_ACCESS_TOKEN) {
454
+ throw new Error('Mapbox API token not configured. Set MAPBOX_ACCESS_TOKEN to use location search. Provide explicit coordinates via bbox instead.');
455
+ }
456
+ const controller = new AbortController();
457
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
458
+ try {
459
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=1`;
460
+ const response = await fetch(url, { signal: controller.signal });
461
+ if (!response.ok)
462
+ throw new Error(`Geocoding failed: ${response.status} ${response.statusText}`);
463
+ const data = await response.json();
464
+ if (!data?.features?.length)
465
+ throw new Error(`No results found for location: ${query}`);
466
+ const feature = data.features[0];
467
+ if (feature.center) {
468
+ return { lat: feature.center[1], lng: feature.center[0] };
469
+ }
470
+ else if (feature.bbox) {
471
+ return { lat: (feature.bbox[1] + feature.bbox[3]) / 2, lng: (feature.bbox[0] + feature.bbox[2]) / 2 };
472
+ }
473
+ else {
474
+ throw new Error(`Unable to determine center point for location: ${query}`);
475
+ }
476
+ }
477
+ catch (err) {
478
+ if (err.name === 'AbortError')
479
+ throw new Error(`Geocoding timed out after ${FETCH_TIMEOUT_MS / 1000}s for: ${query}`);
480
+ throw err;
481
+ }
482
+ finally {
483
+ clearTimeout(timeout);
484
+ }
485
+ }
486
+ function buildShareUrlCircleWithMarkers(center, radiusMeters, elements) {
487
+ const circlePart = `C:${center.lat.toFixed(6)},${center.lng.toFixed(6)},${radiusMeters.toFixed(2)}`;
488
+ const MAX_MARKERS = 30;
489
+ const markerParts = elements
490
+ .filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
491
+ .slice(0, MAX_MARKERS)
492
+ .map(e => {
493
+ let name = e.name || 'Point';
494
+ if (name.length > 30)
495
+ name = name.slice(0, 27) + '...';
496
+ return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
497
+ });
498
+ return `https://vibhorsingh.com/boundingbox/#${[circlePart, ...markerParts].join('|')}`;
499
+ }
437
500
  // ---------------------------------------------------------------------------
438
501
  // Tool Handlers
439
502
  // ---------------------------------------------------------------------------
@@ -533,13 +596,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
533
596
  },
534
597
  {
535
598
  name: "search_overpass",
536
- description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within a bounding box. You must provide the base query. The server automatically wraps it in a bbox filter and returns structured JSON.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- Schools: `nwr[\"amenity\"=\"school\"]`\n- Parking: `nwr[\"amenity\"=\"parking\"]`\n- Highways/Roads: `way[\"highway\"]`",
599
+ description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within a bounding box or radius. The server wraps your query in the appropriate spatial filter and returns structured JSON with a map link.\n\nSEARCH MODES:\n- Bounding box (default): searches a rectangular area. Use for region/area queries ('hospitals in Manhattan', 'parks in downtown Seattle').\n- Circle: searches within an exact radius. Use ONLY when the user specifies an explicit distance ('within 2km of JFK', '500 metres from the Eiffel Tower', '1 mile from Times Square'). Omit radius_meters for vague terms like 'near' or 'close to'.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- Schools: `nwr[\"amenity\"=\"school\"]`\n- Parking: `nwr[\"amenity\"=\"parking\"]`\n- Highways/Roads: `way[\"highway\"]`",
537
600
  inputSchema: {
538
601
  type: "object",
539
602
  properties: {
540
603
  location: {
541
604
  type: "string",
542
- description: "A text location to search for (e.g. 'San Francisco'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
605
+ description: "A text location to search for (e.g. 'San Francisco', 'JFK Airport'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
543
606
  },
544
607
  bbox: {
545
608
  type: "string",
@@ -547,11 +610,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
547
610
  },
548
611
  query: {
549
612
  type: "string",
550
- description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. DO NOT include the bounding box `(S,W,N,E)` or output format (`out json`), the server handles that automatically."
613
+ description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. DO NOT include spatial filters, bounding box, or output directives — the server handles those automatically."
614
+ },
615
+ radius_meters: {
616
+ type: "number",
617
+ description: "Search radius in metres. Enables circle mode — use ONLY when the user specifies an explicit distance (e.g. '2km' → 2000, '500 metres' → 500, '1 mile' → 1609). Omit entirely for area/region queries or vague terms like 'near'."
551
618
  },
552
619
  limit: {
553
620
  type: "number",
554
- description: "Maximum number of elements to return. Helps prevent enormous JSON responses. Default is 100. Set to a higher number if you need more results."
621
+ description: "Maximum number of elements to return. Default is 100. Increase if you need more results."
555
622
  }
556
623
  },
557
624
  required: ["query"],
@@ -737,7 +804,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
737
804
  content: [
738
805
  {
739
806
  type: "text",
740
- text: `H3 Cells Generated: ${cells.length.toLocaleString()}\nResolution: ${resolution}\nCompacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView original bounding box on map: ${shareUrl}`
807
+ text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}`
741
808
  },
742
809
  {
743
810
  type: "text",
@@ -759,7 +826,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
759
826
  content: [
760
827
  {
761
828
  type: "text",
762
- text: `View on map: ${shareUrl}\nNote: you can paste this URL directly in your browser.`
829
+ text: `View on map: ${shareUrl}`
763
830
  },
764
831
  {
765
832
  type: "text",
@@ -775,6 +842,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
775
842
  const args = request.params.arguments || {};
776
843
  const queryRaw = args.query;
777
844
  const limit = args.limit || 100;
845
+ const radiusMeters = args.radius_meters;
846
+ const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
778
847
  if (!queryRaw) {
779
848
  return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
780
849
  }
@@ -782,29 +851,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
782
851
  return { content: [{ type: "text", text: "Error: Query is too long (maximum 2000 characters)." }], isError: true };
783
852
  }
784
853
  if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
785
- return { content: [{ type: "text", text: "Error: Query must not contain direct [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
854
+ return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
786
855
  }
856
+ let overpassQuery;
857
+ let searchModeLabel;
858
+ let centerPoint;
787
859
  let bboxObj;
788
- if (args.location) {
789
- try {
790
- bboxObj = await geocodeLocation(args.location);
791
- }
792
- catch (err) {
793
- if (!args.bbox) {
794
- log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
795
- return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
860
+ if (useCircle) {
861
+ if (args.location) {
862
+ try {
863
+ centerPoint = await geocodeLocationPoint(args.location);
864
+ }
865
+ catch (err) {
866
+ if (args.bbox) {
867
+ log('warn', `Geocoding failed for radius search, using bbox center`, { error: err.message });
868
+ const fb = parseBBox(args.bbox);
869
+ centerPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2 };
870
+ }
871
+ else {
872
+ log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
873
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
874
+ }
796
875
  }
797
- bboxObj = parseBBox(args.bbox);
798
876
  }
799
- }
800
- else if (args.bbox) {
801
- bboxObj = parseBBox(args.bbox);
877
+ else if (args.bbox) {
878
+ const fb = parseBBox(args.bbox);
879
+ centerPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2 };
880
+ }
881
+ else {
882
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
883
+ }
884
+ overpassQuery = `[out:json][timeout:25];\n(${queryRaw}(around:${radiusMeters},${centerPoint.lat},${centerPoint.lng}););\nout center;`;
885
+ searchModeLabel = `circle — ${radiusMeters}m radius around (${centerPoint.lat.toFixed(5)}, ${centerPoint.lng.toFixed(5)})`;
802
886
  }
803
887
  else {
804
- return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
888
+ if (args.location) {
889
+ try {
890
+ bboxObj = await geocodeLocation(args.location);
891
+ }
892
+ catch (err) {
893
+ if (args.bbox) {
894
+ log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
895
+ bboxObj = parseBBox(args.bbox);
896
+ }
897
+ else {
898
+ log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
899
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
900
+ }
901
+ }
902
+ }
903
+ else if (args.bbox) {
904
+ bboxObj = parseBBox(args.bbox);
905
+ }
906
+ else {
907
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
908
+ }
909
+ overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
910
+ searchModeLabel = `bounding box`;
805
911
  }
806
- log('info', `search_overpass starting`, { query: queryRaw });
807
- const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
912
+ log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : 'bbox', radius_meters: radiusMeters });
808
913
  const endpoints = [
809
914
  "https://overpass-api.de/api/interpreter",
810
915
  "https://overpass.kumi.systems/api/interpreter"
@@ -814,7 +919,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
814
919
  }
815
920
  let data = null;
816
921
  let lastError = null;
817
- // Formatting payload precisely for Overpass
818
922
  const params = new URLSearchParams();
819
923
  params.append('data', overpassQuery);
820
924
  for (const endpoint of endpoints) {
@@ -836,7 +940,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
836
940
  throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
837
941
  }
838
942
  data = await response.json();
839
- break; // Success!
943
+ break;
840
944
  }
841
945
  catch (err) {
842
946
  lastError = err;
@@ -848,20 +952,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
848
952
  }
849
953
  if (!data) {
850
954
  if (lastError?.name === 'AbortError') {
851
- return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${(FETCH_TIMEOUT_MS * 4) / 1000}s overall. The query might be too broad.` }], isError: true };
955
+ return { content: [{ type: "text", text: `Error: Overpass API request timed out on all endpoints after ${(FETCH_TIMEOUT_MS * 4) / 1000}s. The query or radius may be too large.` }], isError: true };
852
956
  }
853
957
  throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
854
958
  }
855
- let rawElements = data.elements || [];
856
- // Parse raw elements into structured output
857
959
  const seenIds = new Set();
858
- let parsedElements = rawElements.reduce((acc, e) => {
960
+ let parsedElements = (data.elements || []).reduce((acc, e) => {
859
961
  if (seenIds.has(e.id))
860
962
  return acc;
861
963
  seenIds.add(e.id);
862
964
  const lat = e.lat ?? e.center?.lat;
863
965
  const lon = e.lon ?? e.center?.lon;
864
- // Only keep elements that actually have coordinates and tags (or allow untagged if requested, but structured output is better)
865
966
  if (lat !== undefined && lon !== undefined) {
866
967
  acc.push({
867
968
  id: e.id,
@@ -879,23 +980,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
879
980
  parsedElements = parsedElements.slice(0, limit);
880
981
  truncated = true;
881
982
  }
882
- const shareUrl = buildShareUrl(bboxObj);
883
- log('info', `search_overpass completed`, { elements: elementCount, returned: parsedElements.length, truncated });
983
+ const shareUrl = useCircle && centerPoint
984
+ ? buildShareUrlCircleWithMarkers(centerPoint, radiusMeters, parsedElements)
985
+ : buildShareUrlWithMarkers(bboxObj, parsedElements);
986
+ log('info', `search_overpass completed`, { elements: elementCount, returned: parsedElements.length, truncated, mode: useCircle ? 'circle' : 'bbox' });
884
987
  const responseData = {
988
+ search_mode: useCircle ? 'circle' : 'bbox',
989
+ ...(useCircle && centerPoint
990
+ ? { center: centerPoint, radius_meters: radiusMeters }
991
+ : { original_bbox: bboxObj }),
885
992
  generator: data.generator,
886
993
  osm3s: data.osm3s,
887
994
  elements: parsedElements,
888
995
  total_count: elementCount,
889
996
  returned_count: parsedElements.length,
890
997
  truncated,
891
- original_bbox: bboxObj,
892
998
  share_url: shareUrl
893
999
  };
1000
+ const topResults = parsedElements.slice(0, 5)
1001
+ .map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)})`)
1002
+ .join('\n');
1003
+ const moreNote = parsedElements.length > 5 ? `\n … and ${parsedElements.length - 5} more (see JSON)` : '';
894
1004
  return {
895
1005
  content: [
896
1006
  {
897
1007
  type: "text",
898
- text: `Successfully executed Overpass query.\nFound ${elementCount} elements${truncated ? ` (truncated to ${limit})` : ''}.\n\nSummary:\n${parsedElements.slice(0, 5).map((e) => `- [${e.type}] ${e.id} (${e.name})`).join('\n')}${parsedElements.length > 5 ? '\n... (see JSON for full results)' : ''}\n\nView original bounding box on map: ${shareUrl}`
1008
+ text: `Overpass query executed within a ${searchModeLabel}.\nFound ${elementCount} element${elementCount !== 1 ? 's' : ''}${truncated ? ` (showing first ${limit})` : ''}.\n\nTop results:\n${topResults}${moreNote}\n\nView on map: ${shareUrl}`
899
1009
  },
900
1010
  {
901
1011
  type: "text",
@@ -1064,7 +1174,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1064
1174
  content: [
1065
1175
  {
1066
1176
  type: "text",
1067
- text: `Aggregated ${totalGrouped} elements into ${activeHexes.length} H3 hexagons at resolution ${resolution}.\n\nTop Hexagons by Density:\n${topHexes.map(([hex, count]) => `- Hex ${hex}: ${count} elements`).join('\n')}\n\nView bounding area on map: ${shareUrl}`
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}`
1068
1178
  },
1069
1179
  {
1070
1180
  type: "text",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bbox-mcp-server",
3
3
  "mcpName": "io.github.iamvibhorsingh/bbox-mcp-server",
4
- "version": "1.2.4",
4
+ "version": "1.2.5",
5
5
  "description": "MCP server for bounding box coordinate conversion, EPSG projections, Uber H3 hexagonal indexing, Overpass OSM queries, and shareable map links",
6
6
  "type": "module",
7
7
  "license": "MIT",