bbox-mcp-server 1.1.0 → 1.2.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 +125 -72
- package/dist/index.js +423 -8
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,30 +1,47 @@
|
|
|
1
|
-
# bbox-mcp-server
|
|
1
|
+
# 🌍 bbox-mcp-server
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
[](https://nodejs.org/) [](./LICENSE)
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
## Why
|
|
11
|
+
## Why This Exists
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
39
|
+
Add to your MCP client config:
|
|
23
40
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)</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
|
-
|
|
105
|
+
<br/>
|
|
85
106
|
|
|
86
|
-
|
|
107
|
+
> Try: *"Get the bounding box for Central Park in WKT format"*
|
|
87
108
|
|
|
88
|
-
|
|
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
|
-
| `
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
132
|
+
*Or install globally: `npm install -g bbox-mcp-server`*
|
|
117
133
|
|
|
118
134
|
---
|
|
119
135
|
|
|
120
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
|
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 |
|
|
216
|
+
| `bbox` | string | Input geometry |
|
|
165
217
|
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
180
|
-
|
|
|
181
|
-
|
|
|
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
|
-
|
|
241
|
+
Every tool returns two content blocks:
|
|
188
242
|
|
|
189
|
-
|
|
243
|
+
1. **Human-readable text** — formatted output with the map verification link
|
|
244
|
+
2. **Structured JSON** — all computed data, machine-parseable
|
|
190
245
|
|
|
191
|
-
|
|
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.
|
|
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
|
|
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.
|
|
10
|
+
const SERVER_VERSION = "1.2.0";
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
4
|
-
"description": "MCP server for bounding box coordinate conversion, EPSG projections, Uber H3 hexagonal indexing, and shareable map links",
|
|
3
|
+
"version": "1.2.0",
|
|
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"
|