bbox-mcp-server 1.1.0 → 1.2.1

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 +125 -72
  2. package/dist/index.js +423 -8
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -1,30 +1,47 @@
1
- # bbox-mcp-server
1
+ # 🌍 bbox-mcp-server
2
2
 
3
- An [MCP](https://modelcontextprotocol.io/) server that gives AI agents geospatial superpowers — coordinate conversion, EPSG projections, H3 indexing, and shareable map links.
3
+ The geospatial toolkit for AI agents. **6 tools, zero config** — give any LLM the ability to parse, convert, query, and aggregate spatial data out of the box.
4
4
 
5
- Every response includes a link to [vibhorsingh.com/boundingbox](https://vibhorsingh.com/boundingbox/) so you can visually verify results on an interactive map.
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
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)
8
8
 
9
9
  ---
10
10
 
11
- ## Why use this?
11
+ ## Why This Exists
12
12
 
13
- - **Parse any coordinate format** — raw coords, WKT, GeoJSON, or `ogrinfo` output
14
- - **Project to 3900+ coordinate systems** — unknown codes auto-fetched from [epsg.io](https://epsg.io)
15
- - **Generate H3 hex grids** — cell IDs + optional GeoJSON boundaries
16
- - **Visual verification** — every response links to the [Bounding Box tool](https://vibhorsingh.com/boundingbox/) with coords pre-loaded
13
+ | The problem | How bbox-mcp solves it |
14
+ |---|---|
15
+ | *"I have WKT but the API needs a GeoJSON bbox in EPSG:3857."* | Parses 6 input formats, projects to 3,900+ EPSG codes, outputs in 9 formats — in one call. |
16
+ | *"I keep getting the wrong OSM tags for Overpass queries."* | `list_osm_tags` returns curated tag combos. No more hallucinated `amenity=grocery`. |
17
+ | *"How many hospitals are in this district?"* | `aggregate_overpass_h3` queries Overpass and bins results into H3 hexagons server-side. |
18
+ | *"Is this bounding box actually correct?"* | Every response includes a clickable map link for visual verification. |
19
+
20
+ ---
21
+
22
+ ## Tools at a Glance
23
+
24
+ | Tool | What it does | Key params |
25
+ |---|---|---|
26
+ | `get_bounds` | Convert and project a bbox across formats and coordinate systems | `bbox`, `epsg`, `format`, `coord_order`, `zoom` |
27
+ | `get_h3_indices` | Generate H3 hex cell indices covering a bbox | `bbox`, `resolution`, `compact` |
28
+ | `generate_share_url` | Create a shareable map link for a bbox | `bbox` |
29
+ | `search_overpass` | Query OpenStreetMap via Overpass QL | `bbox`, `query`, `limit` |
30
+ | `list_osm_tags` | Look up correct OSM tags for a category | `category` |
31
+ | `aggregate_overpass_h3` | Run an Overpass query and bin results into H3 hexagons | `bbox`, `query`, `resolution` |
32
+
33
+ All tools accept `location` (natural language, requires Mapbox token) or `bbox` (coordinates, WKT, GeoJSON, etc).
17
34
 
18
35
  ---
19
36
 
20
37
  ## Quick Start
21
38
 
22
- Add the server to your MCP client config — **no install, no API keys required**.
39
+ Add to your MCP client config:
23
40
 
24
- ### Claude Desktop
41
+ <details>
42
+ <summary><b>Claude Desktop</b> <i>(Click to expand)</i></summary>
25
43
 
26
44
  Add to `claude_desktop_config.json`:
27
-
28
45
  ```json
29
46
  {
30
47
  "mcpServers": {
@@ -35,11 +52,12 @@ Add to `claude_desktop_config.json`:
35
52
  }
36
53
  }
37
54
  ```
55
+ </details>
38
56
 
39
- ### Cursor
57
+ <details>
58
+ <summary><b>Cursor</b> <i>(Click to expand)</i></summary>
40
59
 
41
60
  Add to `.cursor/mcp.json`:
42
-
43
61
  ```json
44
62
  {
45
63
  "mcpServers": {
@@ -50,11 +68,12 @@ Add to `.cursor/mcp.json`:
50
68
  }
51
69
  }
52
70
  ```
71
+ </details>
53
72
 
54
- ### Windsurf
73
+ <details>
74
+ <summary><b>Windsurf</b> <i>(Click to expand)</i></summary>
55
75
 
56
76
  Add to `~/.codeium/windsurf/mcp_config.json`:
57
-
58
77
  ```json
59
78
  {
60
79
  "mcpServers": {
@@ -65,11 +84,12 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
65
84
  }
66
85
  }
67
86
  ```
87
+ </details>
68
88
 
69
- ### VS Code
70
-
71
- Add to `.vscode/mcp.json` in your workspace (requires [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot)):
89
+ <details>
90
+ <summary><b>VS Code (GitHub Copilot) or Google Antigravity</b> <i>(Click to expand)</i></summary>
72
91
 
92
+ Add to `.vscode/mcp.json` in your workspace:
73
93
  ```json
74
94
  {
75
95
  "servers": {
@@ -80,12 +100,13 @@ Add to `.vscode/mcp.json` in your workspace (requires [GitHub Copilot](https://m
80
100
  }
81
101
  }
82
102
  ```
103
+ </details>
83
104
 
84
- That's it — start prompting. Try: *"Get the bounding box for Central Park in WKT format"*
105
+ <br/>
85
106
 
86
- ### Optional: enable location search
107
+ > Try: *"Get the bounding box for Central Park in WKT format"*
87
108
 
88
- By default, you pass coordinates directly via the `bbox` parameter. To enable natural-language location search (e.g. *"San Francisco"*), add a [Mapbox access token](https://account.mapbox.com/access-tokens/):
109
+ ### Optional Configuration
89
110
 
90
111
  ```json
91
112
  {
@@ -94,46 +115,40 @@ By default, you pass coordinates directly via the `bbox` parameter. To enable na
94
115
  "command": "npx",
95
116
  "args": ["-y", "bbox-mcp-server"],
96
117
  "env": {
97
- "MAPBOX_ACCESS_TOKEN": "pk.your-token-here"
118
+ "MAPBOX_ACCESS_TOKEN": "pk.your-token-here",
119
+ "OVERPASS_API_URL": "https://your.custom.overpass.instance/api/interpreter"
98
120
  }
99
121
  }
100
122
  }
101
123
  }
102
124
  ```
103
125
 
104
- ### Other options
105
-
106
126
  | Variable | Default | Description |
107
127
  |---|---|---|
108
- | `MAX_H3_CELLS` | `50000` | Safety cap for H3 grid generation at high resolutions |
109
-
110
- ### Install globally (alternative to npx)
111
-
112
- ```bash
113
- npm install -g bbox-mcp-server
114
- ```
128
+ | `MAPBOX_ACCESS_TOKEN` | — | Enables natural language location search (e.g. *"San Francisco"*) |
129
+ | `OVERPASS_API_URL` | auto | Custom Overpass endpoint. By default, rotates between `overpass-api.de` and `kumi.systems`. |
130
+ | `MAX_H3_CELLS` | `50000` | Safety cap for H3 grid generation |
115
131
 
116
- Then use `"command": "bbox-mcp-server"` instead of the npx command in any of the configs above.
132
+ *Or install globally: `npm install -g bbox-mcp-server`*
117
133
 
118
134
  ---
119
135
 
120
- ## Tools
136
+ ## Tool Reference
121
137
 
122
138
  ### `get_bounds`
123
139
 
124
- Convert and project a bounding box across formats and coordinate systems.
140
+ Convert and project a bounding box across 6 input formats, 9 output formats, and 3,900+ coordinate systems. Returns the center point and tile coordinates for the centroid.
125
141
 
126
- **Input** (one of `location` or `bbox` required):
142
+ | Param | Type | Default | Description |
143
+ |---|---|---|---|
144
+ | `bbox` | string | — | Input geometry (coordinates, WKT, GeoJSON, ogrinfo extent) |
145
+ | `epsg` | string | `"4326"` | Target projection. Unknown codes auto-fetched from epsg.io. |
146
+ | `format` | string | `"csv"` | Output: `csv`, `wkt`, `geojson-bbox`, `geojson-polygon`, `leaflet`, `overpass`, `ogc-bbox`, `kml`, `stac-bbox` |
147
+ | `coord_order` | string | `"lng,lat"` | Swap to `"lat,lng"` for APIs that expect latitude first |
148
+ | `zoom` | number | `15` | Zoom level for tile coordinate calculation |
149
+ | `precision` | number | `6` | Decimal places in formatted output |
127
150
 
128
- | Param | Type | Description |
129
- |---|---|---|
130
- | `location` | string | Text search (e.g. `"San Francisco"`). Requires `MAPBOX_ACCESS_TOKEN`. |
131
- | `bbox` | string | Any parseable geometry — `"lat1,lng1,lat2,lng2"`, WKT, GeoJSON, or ogrinfo extent. |
132
- | `epsg` | string | Target EPSG code (default `"4326"`). Unknown codes auto-fetched from epsg.io. |
133
- | `format` | string | Output format: `csv`, `wkt`, `geojson-bbox`, `geojson-polygon`, `leaflet`, `overpass`, `ogc-bbox`, `kml`, `stac-bbox` |
134
- | `precision` | number | Decimal places (default `6`) |
135
-
136
- **Example prompt:** *"Get the bounding box for Central Park in WKT format projected to EPSG:32618"*
151
+ **💡 Prompt:** *"Get the bounding box for Central Park in WKT format projected to EPSG:32618"*
137
152
 
138
153
  ---
139
154
 
@@ -141,78 +156,116 @@ Convert and project a bounding box across formats and coordinate systems.
141
156
 
142
157
  Generate Uber H3 hexagonal cell indices covering a bounding box.
143
158
 
144
- **Input** (one of `location` or `bbox` required):
159
+ | Param | Type | Default | Description |
160
+ |---|---|---|---|
161
+ | `bbox` | string | — | Input geometry |
162
+ | `resolution` | number | — | H3 resolution (0–15) |
163
+ | `compact` | boolean | `false` | Merge cells into coarser parents where possible |
164
+ | `return_geometry` | boolean | `false` | Include GeoJSON hex boundaries |
165
+
166
+ **💡 Prompt:** *"Give me H3 cells at resolution 7 for downtown Chicago, include the hex geometries"*
167
+
168
+ ---
169
+
170
+ ### `search_overpass`
171
+
172
+ Execute an Overpass QL query within a bounding box. Returns structured results with names, coordinates, and tags.
173
+
174
+ | Param | Type | Default | Description |
175
+ |---|---|---|---|
176
+ | `bbox` | string | — | Input geometry |
177
+ | `query` | string | — | Overpass QL (e.g. `nwr["amenity"="cafe"]`). The server wraps it in a bbox filter automatically. |
178
+ | `limit` | number | `100` | Max elements returned |
179
+
180
+ **💡 Prompt:** *"Search for nwr["amenity"="bench"] in Central Park, limit 50"*
181
+
182
+ ---
183
+
184
+ ### `list_osm_tags`
185
+
186
+ Look up the correct OpenStreetMap tags for a category before writing an Overpass query.
145
187
 
146
188
  | Param | Type | Description |
147
189
  |---|---|---|
148
- | `location` | string | Text search. Requires `MAPBOX_ACCESS_TOKEN`. |
149
- | `bbox` | string | Any parseable geometry. |
150
- | `resolution` | number | **Required.** H3 resolution `0`–`15`. |
151
- | `compact` | boolean | Compact mixed-resolution output (default `false`) |
152
- | `return_geometry` | boolean | Include GeoJSON hex boundaries (default `false`) |
190
+ | `category` | string | Broad category (e.g. `"food"`, `"health"`, `"transport"`) |
153
191
 
154
- **Example prompt:** *"Give me H3 cells at resolution 7 for downtown Chicago, include the hex geometries"*
192
+ **💡 Prompt:** *"What are the correct OSM tags for supermarkets?"*
193
+
194
+ ---
195
+
196
+ ### `aggregate_overpass_h3`
197
+
198
+ Run an Overpass query and bin results into H3 hexagons for spatial density analysis. Returns counts per cell and GeoJSON hex boundaries.
199
+
200
+ | Param | Type | Default | Description |
201
+ |---|---|---|---|
202
+ | `bbox` | string | — | Input geometry |
203
+ | `query` | string | — | Overpass QL core query |
204
+ | `resolution` | number | `8` | H3 resolution for binning |
205
+
206
+ **💡 Prompt:** *"Aggregate all hospitals in Seattle into H3 bins at resolution 7"*
155
207
 
156
208
  ---
157
209
 
158
210
  ### `generate_share_url`
159
211
 
160
- Generate a shareable link to visualize a bounding box on the interactive map tool.
212
+ Generate a shareable link to visualize a bounding box on the interactive map at vibhorsingh.com/boundingbox.
161
213
 
162
214
  | Param | Type | Description |
163
215
  |---|---|---|
164
- | `bbox` | string | **Required.** Any parseable geometry. |
216
+ | `bbox` | string | Input geometry |
165
217
 
166
- **Example prompt:** *"Generate a share link for bbox 40.7128,-74.0060,40.7580,-73.9855"*
218
+ **💡 Prompt:** *"Generate a share link for bbox 40.7128,-74.0060,40.7580,-73.9855"*
167
219
 
168
220
  ---
169
221
 
170
222
  ## Supported Input Formats
171
223
 
172
- All tools accept these formats through the `bbox` parameter:
224
+ All tools auto-detect the input format. No need to specify which one you're using.
173
225
 
174
226
  | Format | Example |
175
227
  |---|---|
176
- | **Raw coordinates** | `40.7128,-74.0060,40.7580,-73.9855` |
177
- | **WKT** | `POLYGON((-74.006 40.712, -73.985 40.712, ...))` |
178
- | **GeoJSON** | `{"type":"Feature","geometry":{...}}` |
179
- | **GeoJSON bbox** | `{"bbox":[-74.006,40.712,-73.985,40.758]}` |
180
- | **ogrinfo extent** | `Extent: (-74.006, 40.712) - (-73.985, 40.758)` |
181
- | **Space-separated (GDAL)** | `40.7128 -74.0060 40.7580 -73.9855` |
228
+ | Raw coordinates | `40.7128,-74.0060,40.7580,-73.9855` |
229
+ | WKT | `POLYGON((-74.006 40.712, -73.985 40.712, ...))` |
230
+ | GeoJSON | `{"type":"Feature","geometry":{...}}` |
231
+ | GeoJSON bbox | `{"bbox":[-74.006,40.712,-73.985,40.758]}` |
232
+ | ogrinfo extent | `Extent: (-74.006, 40.712) - (-73.985, 40.758)` |
233
+ | Space-separated | `40.7128 -74.0060 40.7580 -73.9855` |
182
234
 
183
235
  ---
184
236
 
185
- ## For AI Agent Developers
237
+ ## 🤖 For AI Agent Developers
238
+
239
+ ### Response structure
186
240
 
187
- ### What agents get back
241
+ Every tool returns two content blocks:
188
242
 
189
- Every tool response includes two content blocks:
243
+ 1. **Human-readable text** — formatted output with the map verification link
244
+ 2. **Structured JSON** — all computed data, machine-parseable
190
245
 
191
- 1. **Human-readable text** — formatted for display, includes the map link
192
- 2. **Structured JSON** — machine-parseable with all computed data
246
+ Example `get_bounds` JSON response:
193
247
 
194
248
  ```json
195
249
  {
196
250
  "original_wgs84": { "lat1": 40.7128, "lng1": -74.006, "lat2": 40.758, "lng2": -73.9855 },
197
251
  "projected": { "xmin": -8238310.23, "ymin": 4970241.32, "xmax": -8235527.11, "ymax": 4976491.56 },
252
+ "center": { "lat": 40.7354, "lng": -73.99575 },
253
+ "tile_indices": { "z": 15, "x": 9660, "y": 12284 },
198
254
  "epsg": "3857",
255
+ "coord_order": "lng,lat",
199
256
  "area_km2": 8.681,
200
257
  "dimensions": { "width_km": 1.714, "height_km": 5.066 },
201
258
  "share_url": "https://vibhorsingh.com/boundingbox/#40.712800,-74.006000,40.758000,-73.985500"
202
259
  }
203
260
  ```
204
261
 
205
- ### Verification pattern
206
-
207
- The `share_url` in every response opens the visual BBox Finder tool with the exact coordinates pre-loaded. Agents can present this link to users for visual confirmation.
208
-
209
262
  ### Error handling
210
263
 
211
- All errors return `isError: true` with a descriptive message. The server never crashes on bad input — invalid coordinates, unknown EPSG codes, and oversized H3 areas all return clean error responses.
264
+ All errors return `isError: true` with a descriptive message. Invalid coordinates, unknown EPSG codes, and oversized H3 requests all return clean errors — the server never crashes on bad input.
212
265
 
213
266
  ### Logging
214
267
 
215
- Structured JSON logs are written to **stderr** (not stdout, which is reserved for the MCP protocol). Each log entry includes timestamp, level, and context data.
268
+ Structured JSON logs go to **stderr** (stdout is reserved for MCP protocol). Each entry includes timestamp, level, and context.
216
269
 
217
270
  ---
218
271
 
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.1.0";
10
+ const SERVER_VERSION = "1.2.1";
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
@@ -240,11 +240,22 @@ async function ensureProjection(epsg) {
240
240
  clearTimeout(timeout);
241
241
  }
242
242
  }
243
- function getFormattedBox(bbox, format, precision = 6) {
244
- const xmin = bbox.lng1.toFixed(precision);
245
- const ymin = bbox.lat1.toFixed(precision);
246
- const xmax = bbox.lng2.toFixed(precision);
247
- const ymax = bbox.lat2.toFixed(precision);
243
+ function getFormattedBox(bbox, format, precision = 6, coordOrder = "lng,lat") {
244
+ let xmin, ymin, xmax, ymax;
245
+ if (coordOrder === "lat,lng") {
246
+ // Swap X and Y coordinates
247
+ xmin = bbox.lat1.toFixed(precision);
248
+ ymin = bbox.lng1.toFixed(precision);
249
+ xmax = bbox.lat2.toFixed(precision);
250
+ ymax = bbox.lng2.toFixed(precision);
251
+ }
252
+ else {
253
+ // Default lng,lat order
254
+ xmin = bbox.lng1.toFixed(precision);
255
+ ymin = bbox.lat1.toFixed(precision);
256
+ xmax = bbox.lng2.toFixed(precision);
257
+ ymax = bbox.lat2.toFixed(precision);
258
+ }
248
259
  switch (format.toLowerCase()) {
249
260
  case 'wkt':
250
261
  return `POLYGON(( ${xmin} ${ymin}, ${xmax} ${ymin}, ${xmax} ${ymax}, ${xmin} ${ymax}, ${xmin} ${ymin} ))`;
@@ -294,6 +305,13 @@ function calculateBboxArea(bbox) {
294
305
  const { widthKm, heightKm } = calculateBboxDimensions(bbox);
295
306
  return widthKm * heightKm;
296
307
  }
308
+ function latLngToTile(lat, lng, zoom) {
309
+ const n = Math.pow(2, zoom);
310
+ const x = Math.floor((lng + 180) / 360 * n);
311
+ const latRad = lat * Math.PI / 180;
312
+ const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
313
+ return { z: zoom, x, y };
314
+ }
297
315
  function buildShareUrl(bbox) {
298
316
  const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
299
317
  return `https://vibhorsingh.com/boundingbox/#${hash}`;
@@ -439,6 +457,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
439
457
  description: "Format of the output. Options: wkt, geojson-bbox, leaflet, overpass, ogc-bbox, kml, geojson-polygon, csv, stac-bbox. Default: csv.",
440
458
  enum: ["wkt", "geojson-bbox", "leaflet", "overpass", "ogc-bbox", "kml", "geojson-polygon", "csv", "stac-bbox"]
441
459
  },
460
+ coord_order: {
461
+ type: "string",
462
+ description: "Toggle between 'lng,lat' (default) and 'lat,lng' coordinate ordering in the formatted output. Useful for APIs that expect swapped coordinate orders.",
463
+ enum: ["lng,lat", "lat,lng"]
464
+ },
465
+ zoom: {
466
+ type: "number",
467
+ description: "The map zoom level (0-22) used to calculate the map tile coordinates for the centroid. Defaults to 15."
468
+ },
442
469
  precision: {
443
470
  type: "number",
444
471
  description: "The number of decimal places to output coordinate strings as (defaults to 6)."
@@ -497,6 +524,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
497
524
  },
498
525
  required: ["bbox"],
499
526
  },
527
+ },
528
+ {
529
+ name: "search_overpass",
530
+ 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\"]`",
531
+ inputSchema: {
532
+ type: "object",
533
+ properties: {
534
+ location: {
535
+ type: "string",
536
+ description: "A text location to search for (e.g. 'San Francisco'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
537
+ },
538
+ bbox: {
539
+ type: "string",
540
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent. Either 'location' or 'bbox' MUST be provided.",
541
+ },
542
+ query: {
543
+ type: "string",
544
+ 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."
545
+ },
546
+ limit: {
547
+ type: "number",
548
+ 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."
549
+ }
550
+ },
551
+ required: ["query"],
552
+ oneOf: [
553
+ { required: ["location"] },
554
+ { required: ["bbox"] }
555
+ ]
556
+ },
557
+ },
558
+ {
559
+ name: "list_osm_tags",
560
+ description: "Discovery tool to look up the correct OpenStreetMap tags for a given category. Helps prevent hallucinating incorrect tags before writing an Overpass query.",
561
+ inputSchema: {
562
+ type: "object",
563
+ properties: {
564
+ category: {
565
+ type: "string",
566
+ description: "Broad category to look up (e.g. 'food', 'health', 'transport', 'leisure', 'retail')."
567
+ }
568
+ },
569
+ required: ["category"]
570
+ }
571
+ },
572
+ {
573
+ name: "aggregate_overpass_h3",
574
+ description: "Executes an Overpass query and bins the results into H3 hexagons at a specified resolution to analyze spatial density.",
575
+ inputSchema: {
576
+ type: "object",
577
+ properties: {
578
+ location: {
579
+ type: "string",
580
+ description: "A text location to search for (e.g. 'Seattle')."
581
+ },
582
+ bbox: {
583
+ type: "string",
584
+ description: "The bounding box geometry to parse."
585
+ },
586
+ query: {
587
+ type: "string",
588
+ description: "The Overpass QL core query (e.g. `nwr[\"amenity\"=\"cafe\"]`)."
589
+ },
590
+ resolution: {
591
+ type: "number",
592
+ description: "The H3 resolution level (0-15) for binning. Default is 8."
593
+ }
594
+ },
595
+ required: ["query", "resolution"],
596
+ oneOf: [
597
+ { required: ["location"] },
598
+ { required: ["bbox"] }
599
+ ]
600
+ }
500
601
  }
501
602
  ],
502
603
  };
@@ -530,12 +631,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
530
631
  }
531
632
  let epsg = args.epsg || "4326";
532
633
  let format = args.format || "csv";
634
+ let coordOrder = args.coord_order || "lng,lat";
635
+ let zoom = args.zoom || 15;
533
636
  let precision = args.precision || 6;
534
637
  epsg = epsg.replace(/[^0-9]/g, "");
535
638
  // Dynamically fetch projection if not bundled
536
639
  await ensureProjection(epsg);
537
640
  const projCoords = projectBounds(bboxObj, epsg);
538
- const WGS84Formatted = getFormattedBox(bboxObj, format, precision);
641
+ const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
539
642
  // Build projected bbox for formatting (uses projected values)
540
643
  const projBbox = {
541
644
  lat1: projCoords.ymin,
@@ -543,10 +646,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
543
646
  lat2: projCoords.ymax,
544
647
  lng2: projCoords.xmax
545
648
  };
546
- const projectedFormatted = getFormattedBox(projBbox, format, precision);
649
+ const projectedFormatted = getFormattedBox(projBbox, format, precision, coordOrder);
547
650
  const shareUrl = buildShareUrl(bboxObj);
548
651
  const dims = calculateBboxDimensions(bboxObj);
549
652
  const areaKm2 = calculateBboxArea(bboxObj);
653
+ // Calculate center and tile indices based on WGS84 original bounds
654
+ const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
655
+ const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
656
+ const tileIndices = latLngToTile(centerLat, centerLng, zoom);
550
657
  log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
551
658
  return {
552
659
  content: [
@@ -559,8 +666,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
559
666
  text: JSON.stringify({
560
667
  original_wgs84: bboxObj,
561
668
  projected: projCoords,
669
+ center: {
670
+ lat: centerLat,
671
+ lng: centerLng
672
+ },
673
+ tile_indices: tileIndices,
562
674
  epsg,
563
675
  format,
676
+ coord_order: coordOrder,
564
677
  precision,
565
678
  area_km2: areaKm2,
566
679
  dimensions: {
@@ -652,6 +765,308 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
652
765
  ]
653
766
  };
654
767
  }
768
+ else if (toolName === "search_overpass") {
769
+ const args = request.params.arguments || {};
770
+ const queryRaw = args.query;
771
+ const limit = args.limit || 100;
772
+ if (!queryRaw) {
773
+ return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
774
+ }
775
+ if (queryRaw.length > 2000) {
776
+ return { content: [{ type: "text", text: "Error: Query is too long (maximum 2000 characters)." }], isError: true };
777
+ }
778
+ if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
779
+ return { content: [{ type: "text", text: "Error: Query must not contain direct [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
780
+ }
781
+ let bboxObj;
782
+ if (args.location) {
783
+ try {
784
+ bboxObj = await geocodeLocation(args.location);
785
+ }
786
+ catch (err) {
787
+ if (!args.bbox) {
788
+ log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
789
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
790
+ }
791
+ bboxObj = parseBBox(args.bbox);
792
+ }
793
+ }
794
+ else if (args.bbox) {
795
+ bboxObj = parseBBox(args.bbox);
796
+ }
797
+ else {
798
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
799
+ }
800
+ log('info', `search_overpass starting`, { query: queryRaw });
801
+ const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
802
+ const endpoints = [
803
+ "https://overpass-api.de/api/interpreter",
804
+ "https://overpass.kumi.systems/api/interpreter"
805
+ ];
806
+ if (process.env.OVERPASS_API_URL) {
807
+ endpoints.unshift(process.env.OVERPASS_API_URL);
808
+ }
809
+ let data = null;
810
+ let lastError = null;
811
+ // Formatting payload precisely for Overpass
812
+ const params = new URLSearchParams();
813
+ params.append('data', overpassQuery);
814
+ for (const endpoint of endpoints) {
815
+ const controller = new AbortController();
816
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
817
+ try {
818
+ log('info', `Executing Overpass query at ${endpoint}`);
819
+ const response = await fetch(endpoint, {
820
+ method: "POST",
821
+ body: params.toString(),
822
+ headers: {
823
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
824
+ 'Accept': 'application/json',
825
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
826
+ },
827
+ signal: controller.signal
828
+ });
829
+ if (!response.ok) {
830
+ throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
831
+ }
832
+ data = await response.json();
833
+ break; // Success!
834
+ }
835
+ catch (err) {
836
+ lastError = err;
837
+ log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
838
+ }
839
+ finally {
840
+ clearTimeout(timeout);
841
+ }
842
+ }
843
+ if (!data) {
844
+ if (lastError?.name === 'AbortError') {
845
+ 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 };
846
+ }
847
+ throw new Error(`Overpass query failed on all endpoints: ${lastError?.message}`);
848
+ }
849
+ let rawElements = data.elements || [];
850
+ // Parse raw elements into structured output
851
+ const seenIds = new Set();
852
+ let parsedElements = rawElements.reduce((acc, e) => {
853
+ if (seenIds.has(e.id))
854
+ return acc;
855
+ seenIds.add(e.id);
856
+ const lat = e.lat ?? e.center?.lat;
857
+ const lon = e.lon ?? e.center?.lon;
858
+ // Only keep elements that actually have coordinates and tags (or allow untagged if requested, but structured output is better)
859
+ if (lat !== undefined && lon !== undefined) {
860
+ acc.push({
861
+ id: e.id,
862
+ type: e.type,
863
+ name: e.tags?.name || "Unnamed",
864
+ coordinates: { lat, lon },
865
+ tags: e.tags || {}
866
+ });
867
+ }
868
+ return acc;
869
+ }, []);
870
+ const elementCount = parsedElements.length;
871
+ let truncated = false;
872
+ if (parsedElements.length > limit) {
873
+ parsedElements = parsedElements.slice(0, limit);
874
+ truncated = true;
875
+ }
876
+ const shareUrl = buildShareUrl(bboxObj);
877
+ log('info', `search_overpass completed`, { elements: elementCount, returned: parsedElements.length, truncated });
878
+ const responseData = {
879
+ generator: data.generator,
880
+ osm3s: data.osm3s,
881
+ elements: parsedElements,
882
+ total_count: elementCount,
883
+ returned_count: parsedElements.length,
884
+ truncated,
885
+ original_bbox: bboxObj,
886
+ share_url: shareUrl
887
+ };
888
+ return {
889
+ content: [
890
+ {
891
+ type: "text",
892
+ 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}`
893
+ },
894
+ {
895
+ type: "text",
896
+ text: JSON.stringify(responseData, null, 2)
897
+ }
898
+ ]
899
+ };
900
+ }
901
+ else if (toolName === "list_osm_tags") {
902
+ const args = request.params.arguments || {};
903
+ const category = (args.category || "").toLowerCase();
904
+ let recommendations = "";
905
+ switch (true) {
906
+ case category.includes('food') || category.includes('restaurant') || category.includes('cafe'):
907
+ recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]`;
908
+ break;
909
+ case category.includes('health') || category.includes('medical'):
910
+ recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]`;
911
+ break;
912
+ case category.includes('transport') || category.includes('transit') || category.includes('traffic'):
913
+ 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"]`;
914
+ break;
915
+ case category.includes('leisure') || category.includes('park') || category.includes('entertainment'):
916
+ recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
917
+ break;
918
+ case category.includes('retail') || category.includes('shop') || category.includes('store'):
919
+ recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]`;
920
+ break;
921
+ case category.includes('education') || category.includes('school'):
922
+ recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
923
+ break;
924
+ case category.includes('tourism') || category.includes('hotel'):
925
+ recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\nInformation: nwr["tourism"="information"]`;
926
+ break;
927
+ default:
928
+ recommendations = `Top categories:\n- Food: nwr["amenity"="restaurant"], nwr["amenity"="cafe"]\n- Health: nwr["amenity"="hospital"]\n- Transport: nwr["highway"="bus_stop"], nwr["amenity"="parking"]\n- Leisure: nwr["leisure"="park"]\n- Retail: nwr["shop"="supermarket"]\n- Education: nwr["amenity"="school"]\n\nPlease try searching for one of those broader categories (e.g. "health") for more detailed tag combinations.`;
929
+ break;
930
+ }
931
+ return {
932
+ content: [{
933
+ type: "text",
934
+ text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can usually just use these directly as the 'query' argument in search_overpass.`
935
+ }]
936
+ };
937
+ }
938
+ else if (toolName === "aggregate_overpass_h3") {
939
+ const args = request.params.arguments || {};
940
+ const queryRaw = args.query;
941
+ const resolution = args.resolution || 8;
942
+ if (!queryRaw) {
943
+ return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
944
+ }
945
+ let bboxObj;
946
+ if (args.location) {
947
+ try {
948
+ bboxObj = await geocodeLocation(args.location);
949
+ }
950
+ catch (err) {
951
+ if (!args.bbox) {
952
+ return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
953
+ }
954
+ bboxObj = parseBBox(args.bbox);
955
+ }
956
+ }
957
+ else if (args.bbox) {
958
+ bboxObj = parseBBox(args.bbox);
959
+ }
960
+ else {
961
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
962
+ }
963
+ log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution });
964
+ // Ensure H3 resolution is valid early
965
+ if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
966
+ return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
967
+ }
968
+ const overpassQuery = `[out:json][timeout:25][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${queryRaw};);\nout center;`;
969
+ let data = null;
970
+ let lastError = null;
971
+ const params = new URLSearchParams();
972
+ params.append('data', overpassQuery);
973
+ const endpoints = [
974
+ "https://overpass-api.de/api/interpreter",
975
+ "https://overpass.kumi.systems/api/interpreter"
976
+ ];
977
+ if (process.env.OVERPASS_API_URL) {
978
+ endpoints.unshift(process.env.OVERPASS_API_URL);
979
+ }
980
+ for (const endpoint of endpoints) {
981
+ const controller = new AbortController();
982
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
983
+ try {
984
+ const response = await fetch(endpoint, {
985
+ method: "POST",
986
+ body: params.toString(),
987
+ headers: {
988
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
989
+ 'Accept': 'application/json',
990
+ 'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
991
+ },
992
+ signal: controller.signal
993
+ });
994
+ if (!response.ok)
995
+ throw new Error(`Status: ${response.status}`);
996
+ data = await response.json();
997
+ break;
998
+ }
999
+ catch (err) {
1000
+ lastError = err;
1001
+ }
1002
+ finally {
1003
+ clearTimeout(timeout);
1004
+ }
1005
+ }
1006
+ if (!data) {
1007
+ return { content: [{ type: "text", text: `Error: Overpass query failed: ${lastError?.message}` }], isError: true };
1008
+ }
1009
+ let rawElements = data.elements || [];
1010
+ // Bin into H3 Hexagons
1011
+ const hexCounts = {};
1012
+ const hexElements = {};
1013
+ const seenIds = new Set();
1014
+ rawElements.forEach((e) => {
1015
+ if (seenIds.has(e.id))
1016
+ return;
1017
+ seenIds.add(e.id);
1018
+ const lat = e.lat ?? e.center?.lat;
1019
+ const lon = e.lon ?? e.center?.lon;
1020
+ if (lat !== undefined && lon !== undefined) {
1021
+ const h3Index = h3.latLngToCell(lat, lon, resolution);
1022
+ hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
1023
+ if (!hexElements[h3Index])
1024
+ hexElements[h3Index] = [];
1025
+ // Keep element data minimal to avoid massive payloads
1026
+ hexElements[h3Index].push({
1027
+ id: e.id,
1028
+ name: e.tags?.name || "Unnamed"
1029
+ });
1030
+ }
1031
+ });
1032
+ const shareUrl = buildShareUrl(bboxObj);
1033
+ const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
1034
+ // Generate GeoJSON of the active hexes
1035
+ const activeHexes = Object.keys(hexCounts);
1036
+ const geojson = h3CellsToGeoJSON(activeHexes);
1037
+ // Inject the counts into the GeoJSON properties
1038
+ geojson.features.forEach((f) => {
1039
+ const cellId = f.properties.h3_index;
1040
+ f.properties.count = hexCounts[cellId];
1041
+ f.properties.elements = hexElements[cellId];
1042
+ });
1043
+ log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
1044
+ const responseData = {
1045
+ resolution,
1046
+ total_elements: totalGrouped,
1047
+ active_hex_count: activeHexes.length,
1048
+ hex_counts: hexCounts,
1049
+ hex_geojson: geojson,
1050
+ original_bbox: bboxObj,
1051
+ share_url: shareUrl
1052
+ };
1053
+ // Sort map for display
1054
+ const topHexes = Object.entries(hexCounts)
1055
+ .sort((a, b) => b[1] - a[1])
1056
+ .slice(0, 10);
1057
+ return {
1058
+ content: [
1059
+ {
1060
+ type: "text",
1061
+ 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}`
1062
+ },
1063
+ {
1064
+ type: "text",
1065
+ text: JSON.stringify(responseData, null, 2)
1066
+ }
1067
+ ]
1068
+ };
1069
+ }
655
1070
  else {
656
1071
  log('warn', `Unknown tool requested: ${toolName}`);
657
1072
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bbox-mcp-server",
3
- "version": "1.1.0",
4
- "description": "MCP server for bounding box coordinate conversion, EPSG projections, Uber H3 hexagonal indexing, and shareable map links",
3
+ "version": "1.2.1",
4
+ "description": "MCP server for bounding box coordinate conversion, EPSG projections, Uber H3 hexagonal indexing, Overpass OSM queries, and shareable map links",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Vibhor Singh",
@@ -24,7 +24,11 @@
24
24
  "h3",
25
25
  "hexagonal-grid",
26
26
  "wkt",
27
- "geojson"
27
+ "geojson",
28
+ "overpass",
29
+ "osm",
30
+ "openstreetmap",
31
+ "poi"
28
32
  ],
29
33
  "bin": {
30
34
  "bbox-mcp-server": "./dist/index.js"