bbox-mcp-server 1.1.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/dist/index.js ADDED
@@ -0,0 +1,692 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import * as h3 from "h3-js";
6
+ import proj4 from "proj4";
7
+ import proj4defs from "./proj4defs.json" with { type: "json" };
8
+ import { wktToGeoJSON as parseWKT } from "@terraformer/wkt";
9
+ const SERVER_NAME = "bbox-mcp-server";
10
+ const SERVER_VERSION = "1.1.0";
11
+ const MAX_H3_CELLS = process.env.MAX_H3_CELLS ? parseInt(process.env.MAX_H3_CELLS, 10) : 50000;
12
+ const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN;
13
+ const MAX_INPUT_LENGTH = 1_000_000; // 1 MB input size guard
14
+ const FETCH_TIMEOUT_MS = 10_000; // 10 second timeout for external API calls
15
+ // ---------------------------------------------------------------------------
16
+ // Logging Utility
17
+ // ---------------------------------------------------------------------------
18
+ function log(level, message, data) {
19
+ const entry = {
20
+ timestamp: new Date().toISOString(),
21
+ level,
22
+ server: SERVER_NAME,
23
+ message,
24
+ ...data
25
+ };
26
+ // MCP servers use stdio for protocol messages — logs go to stderr
27
+ console.error(JSON.stringify(entry));
28
+ }
29
+ const server = new Server({
30
+ name: SERVER_NAME,
31
+ version: SERVER_VERSION,
32
+ }, {
33
+ capabilities: {
34
+ tools: {},
35
+ },
36
+ });
37
+ // Initialize Proj4 Data once
38
+ function initProj4() {
39
+ const defs = proj4defs;
40
+ let count = 0;
41
+ for (const [code, def] of Object.entries(defs)) {
42
+ if (def.length >= 2 && def[1]) {
43
+ proj4.defs(`EPSG:${code}`, def[1]);
44
+ count++;
45
+ }
46
+ }
47
+ log('info', `Loaded ${count} EPSG projection definitions from bundled proj4defs.json`);
48
+ }
49
+ initProj4();
50
+ // ---------------------------------------------------------------------------
51
+ // Shared Utilities
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Recursively extracts [lng, lat] coordinate pairs from any nested structure
55
+ * (GeoJSON, WKT parsed output, etc.). Used by both GeoJSON and WKT sniffers.
56
+ */
57
+ function extractCoords(obj) {
58
+ const result = [];
59
+ const stack = [obj];
60
+ while (stack.length > 0) {
61
+ const current = stack.pop();
62
+ if (Array.isArray(current)) {
63
+ if (current.length >= 2 && typeof current[0] === 'number' && typeof current[1] === 'number') {
64
+ result.push([current[0], current[1]]); // [lng, lat]
65
+ }
66
+ else {
67
+ for (let i = current.length - 1; i >= 0; i--) {
68
+ stack.push(current[i]);
69
+ }
70
+ }
71
+ }
72
+ else if (current && typeof current === 'object') {
73
+ const values = Object.values(current);
74
+ for (let i = values.length - 1; i >= 0; i--) {
75
+ stack.push(values[i]);
76
+ }
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+ /**
82
+ * Safe min/max over an array — avoids stack overflow from Function.apply
83
+ * on large coordinate arrays (>65k elements).
84
+ */
85
+ function safeMin(arr) {
86
+ let min = Infinity;
87
+ for (let i = 0; i < arr.length; i++) {
88
+ if (arr[i] < min)
89
+ min = arr[i];
90
+ }
91
+ return min;
92
+ }
93
+ function safeMax(arr) {
94
+ let max = -Infinity;
95
+ for (let i = 0; i < arr.length; i++) {
96
+ if (arr[i] > max)
97
+ max = arr[i];
98
+ }
99
+ return max;
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Pure Functions Ported from bbox.js
103
+ // ---------------------------------------------------------------------------
104
+ function validateBBox(lat1, lng1, lat2, lng2) {
105
+ if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2))
106
+ return false;
107
+ if (lat1 < -90 || lat1 > 90 || lat2 < -90 || lat2 > 90)
108
+ return false;
109
+ if (lng1 < -180 || lng1 > 180 || lng2 < -180 || lng2 > 180)
110
+ return false;
111
+ return true;
112
+ }
113
+ function parseBBox(input) {
114
+ if (!input || typeof input !== 'string') {
115
+ throw new Error(`Invalid bbox input.`);
116
+ }
117
+ // Input size guard — prevent blocking the event loop on huge payloads
118
+ if (input.length > MAX_INPUT_LENGTH) {
119
+ throw new Error(`Input too large (${(input.length / 1024).toFixed(0)} KB). Maximum allowed is ${(MAX_INPUT_LENGTH / 1024).toFixed(0)} KB.`);
120
+ }
121
+ // --- 1. ogrinfo Extent Sniffer ---
122
+ // Matches: "Extent: (-74.006000, 40.712800) - (-73.985500, 40.758000)"
123
+ const ogrMatch = input.match(/Extent\:\s\((.*)\)/);
124
+ if (ogrMatch && ogrMatch[1]) {
125
+ try {
126
+ const pairs = ogrMatch[1].split(") - (");
127
+ if (pairs.length === 2) {
128
+ const minCoords = pairs[0].replace('(', '').split(',').map(s => parseFloat(s.trim()));
129
+ const maxCoords = pairs[1].replace(')', '').split(',').map(s => parseFloat(s.trim()));
130
+ // Assuming format: (minX, minY) - (maxX, maxY) where X=lng, Y=lat
131
+ return ensureValidBBox(minCoords[1], minCoords[0], maxCoords[1], maxCoords[0]);
132
+ }
133
+ }
134
+ catch (e) { /* Fall through to next parser */ }
135
+ }
136
+ // --- 2. GeoJSON Sniffer ---
137
+ try {
138
+ const json = JSON.parse(input);
139
+ if (json.bbox && json.bbox.length === 4) {
140
+ // standard GeoJSON bbox: [minX, minY, maxX, maxY]
141
+ return ensureValidBBox(json.bbox[1], json.bbox[0], json.bbox[3], json.bbox[2]);
142
+ }
143
+ // Multi-Geometry Coordinate Extraction (using shared, stack-safe utility)
144
+ const allCoords = extractCoords(json);
145
+ if (allCoords.length > 0) {
146
+ const lngs = allCoords.map(c => c[0]);
147
+ const lats = allCoords.map(c => c[1]);
148
+ return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
149
+ }
150
+ }
151
+ catch (e) { /* Not valid JSON, fall through */ }
152
+ // --- 3. WKT Sniffer ---
153
+ if (input.toUpperCase().includes("POLYGON") || input.toUpperCase().includes("POINT") || input.toUpperCase().includes("LINESTRING")) {
154
+ try {
155
+ const geojson = parseWKT(input);
156
+ const allCoords = extractCoords(geojson.coordinates || geojson);
157
+ if (allCoords.length > 0) {
158
+ const lngs = allCoords.map(c => c[0]);
159
+ const lats = allCoords.map(c => c[1]);
160
+ return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
161
+ }
162
+ }
163
+ catch (e) { /* Full WKT parse failed */ }
164
+ }
165
+ // --- 4. Standard Coordinate Array Sniffer ---
166
+ // Strip brackets/parentheses and split by commas or spaces
167
+ const cleanInput = input.replace(/[\[\]\(\)]/g, '').trim();
168
+ // Support space-separated (GDAL) or comma-separated
169
+ const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
170
+ if (parts.length === 4) {
171
+ // Assume format: lat1, lng1, lat2, lng2 OR minX, minY, maxX, maxY
172
+ // BBox finder defaults to minLat, minLng, maxLat, maxLng
173
+ return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
174
+ }
175
+ throw new Error(`Failed to parse bounding box. Input must be one of: WKT, GeoJSON, ogrinfo extent, or "lat1,lng1,lat2,lng2" string. Got: ${input.substring(0, 50)}...`);
176
+ }
177
+ function ensureValidBBox(lat1, lng1, lat2, lng2) {
178
+ if (!validateBBox(lat1, lng1, lat2, lng2)) {
179
+ throw new Error(`Invalid coordinates or out of bounds: lat[-90, 90], lng[-180, 180]. Provided: ${lat1}, ${lng1}, ${lat2}, ${lng2}`);
180
+ }
181
+ // Ensure we sort so that lat1 < lat2 and lng1 < lng2
182
+ return {
183
+ lat1: Math.min(lat1, lat2),
184
+ lng1: Math.min(lng1, lng2),
185
+ lat2: Math.max(lat1, lat2),
186
+ lng2: Math.max(lng1, lng2)
187
+ };
188
+ }
189
+ function projectBounds(bbox, epsg) {
190
+ if (epsg === "4326") {
191
+ return { xmin: bbox.lng1, ymin: bbox.lat1, xmax: bbox.lng2, ymax: bbox.lat2 };
192
+ }
193
+ const epsgKey = `EPSG:${epsg}`;
194
+ if (!proj4.defs(epsgKey)) {
195
+ throw new Error(`Unknown EPSG code: ${epsg}. Make sure it exists in proj4defs.`);
196
+ }
197
+ const p1 = proj4(epsgKey, [bbox.lng1, bbox.lat1]);
198
+ const p2 = proj4(epsgKey, [bbox.lng2, bbox.lat2]);
199
+ return {
200
+ xmin: p1[0],
201
+ ymin: p1[1],
202
+ xmax: p2[0],
203
+ ymax: p2[1],
204
+ };
205
+ }
206
+ /**
207
+ * Attempts to dynamically fetch an EPSG projection definition from epsg.io
208
+ * if it's not in the bundled proj4defs.json. Caches the result for future
209
+ * calls within the same server session.
210
+ */
211
+ async function ensureProjection(epsg) {
212
+ const epsgKey = `EPSG:${epsg}`;
213
+ if (proj4.defs(epsgKey)) {
214
+ return; // Already loaded
215
+ }
216
+ log('info', `EPSG:${epsg} not in bundled defs, fetching from epsg.io`);
217
+ const controller = new AbortController();
218
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
219
+ try {
220
+ const response = await fetch(`https://epsg.io/${epsg}.proj4`, {
221
+ signal: controller.signal,
222
+ });
223
+ if (!response.ok) {
224
+ throw new Error(`epsg.io returned status ${response.status}`);
225
+ }
226
+ const proj4String = await response.text();
227
+ if (!proj4String || proj4String.includes('<!DOCTYPE html>') || proj4String.trim().length === 0) {
228
+ throw new Error(`Invalid proj4 string received for EPSG:${epsg}`);
229
+ }
230
+ proj4.defs(epsgKey, proj4String.trim());
231
+ log('info', `Successfully loaded EPSG:${epsg} from epsg.io`);
232
+ }
233
+ catch (err) {
234
+ if (err.name === 'AbortError') {
235
+ throw new Error(`Timeout fetching projection EPSG:${epsg} from epsg.io (${FETCH_TIMEOUT_MS / 1000}s). Try again or use a bundled EPSG code.`);
236
+ }
237
+ throw new Error(`Unknown EPSG code: ${epsg}. Not in bundled definitions and could not fetch from epsg.io: ${err.message}`);
238
+ }
239
+ finally {
240
+ clearTimeout(timeout);
241
+ }
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);
248
+ switch (format.toLowerCase()) {
249
+ case 'wkt':
250
+ return `POLYGON(( ${xmin} ${ymin}, ${xmax} ${ymin}, ${xmax} ${ymax}, ${xmin} ${ymax}, ${xmin} ${ymin} ))`;
251
+ case 'geojson-bbox':
252
+ return `[${xmin}, ${ymin}, ${xmax}, ${ymax}]`;
253
+ case 'leaflet':
254
+ return `[[${ymin}, ${xmin}], [${ymax}, ${xmax}]]`;
255
+ case 'overpass':
256
+ return `[bbox:${ymin},${xmin},${ymax},${xmax}]`;
257
+ case 'ogc-bbox':
258
+ return `BBOX=${xmin},${ymin},${xmax},${ymax},EPSG:4326`;
259
+ case 'kml':
260
+ return `<LatLonBox>\n <north>${ymax}</north>\n <south>${ymin}</south>\n <east>${xmax}</east>\n <west>${xmin}</west>\n</LatLonBox>`;
261
+ case 'geojson-polygon':
262
+ return JSON.stringify({
263
+ "type": "Feature",
264
+ "properties": {},
265
+ "geometry": {
266
+ "type": "Polygon",
267
+ "coordinates": [[[parseFloat(xmin), parseFloat(ymin)], [parseFloat(xmax), parseFloat(ymin)], [parseFloat(xmax), parseFloat(ymax)], [parseFloat(xmin), parseFloat(ymax)], [parseFloat(xmin), parseFloat(ymin)]]]
268
+ },
269
+ "bbox": [parseFloat(xmin), parseFloat(ymin), parseFloat(xmax), parseFloat(ymax)]
270
+ }, null, 2);
271
+ case 'csv':
272
+ return `${xmin},${ymin},${xmax},${ymax}`;
273
+ case 'stac-bbox':
274
+ return `[${xmin}, ${ymin}, ${xmax}, ${ymax}]`;
275
+ default:
276
+ throw new Error(`Unknown format: ${format}. Supported formats: wkt, geojson-bbox, leaflet, overpass, ogc-bbox, kml, geojson-polygon, csv, stac-bbox.`);
277
+ }
278
+ }
279
+ // ---------------------------------------------------------------------------
280
+ // Geographic Math Formulas (Ported from bbox.js)
281
+ // ---------------------------------------------------------------------------
282
+ function calculateBboxDimensions(bbox) {
283
+ const R = 6371; // Earth's radius in km
284
+ const lat1 = bbox.lat1 * Math.PI / 180;
285
+ const lat2 = bbox.lat2 * Math.PI / 180;
286
+ const lng1 = bbox.lng1 * Math.PI / 180;
287
+ const lng2 = bbox.lng2 * Math.PI / 180;
288
+ // Use Haversine distance conceptually matching leaflet distanceTo
289
+ const widthKm = R * Math.abs(lng2 - lng1) * Math.cos((lat1 + lat2) / 2);
290
+ const heightKm = R * Math.abs(lat2 - lat1);
291
+ return { widthKm, heightKm };
292
+ }
293
+ function calculateBboxArea(bbox) {
294
+ const { widthKm, heightKm } = calculateBboxDimensions(bbox);
295
+ return widthKm * heightKm;
296
+ }
297
+ function buildShareUrl(bbox) {
298
+ const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
299
+ return `https://vibhorsingh.com/boundingbox/#${hash}`;
300
+ }
301
+ function getH3Cells(bbox, resolution, compact) {
302
+ if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
303
+ throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
304
+ }
305
+ // Safety limit estimation (rough check)
306
+ const avgHexArea = h3.getHexagonAreaAvg(resolution, h3.UNITS.km2);
307
+ // Calculate approximate width and height using simple distance
308
+ const dx = (bbox.lng2 - bbox.lng1) * 111.32 * Math.cos(((bbox.lat1 + bbox.lat2) / 2) * Math.PI / 180);
309
+ const dy = (bbox.lat2 - bbox.lat1) * 111.32;
310
+ const areaKm2 = dx * dy;
311
+ const estimatedCount = areaKm2 / avgHexArea;
312
+ if (estimatedCount > MAX_H3_CELLS * 2) {
313
+ throw new Error(`Area too large for resolution ${resolution}. Estimated ${Math.floor(estimatedCount).toLocaleString()} cells. Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}.`);
314
+ }
315
+ // H3 v4 polygon format: [ [lat, lng]... ]
316
+ const polygon = [
317
+ [bbox.lat1, bbox.lng1],
318
+ [bbox.lat2, bbox.lng1],
319
+ [bbox.lat2, bbox.lng2],
320
+ [bbox.lat1, bbox.lng2],
321
+ [bbox.lat1, bbox.lng1]
322
+ ];
323
+ let cells = h3.polygonToCells(polygon, resolution);
324
+ if (cells.length > MAX_H3_CELLS) {
325
+ throw new Error(`Too many cells generated (${cells.length.toLocaleString()}). Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}. Try a lower resolution or smaller bounds.`);
326
+ }
327
+ if (compact) {
328
+ cells = h3.compactCells(cells);
329
+ }
330
+ return cells;
331
+ }
332
+ /**
333
+ * Converts H3 cell IDs to GeoJSON FeatureCollection with hex boundary polygons.
334
+ */
335
+ function h3CellsToGeoJSON(cells) {
336
+ const features = cells.map(cellId => {
337
+ const boundary = h3.cellToBoundary(cellId);
338
+ // h3 returns [lat, lng] pairs — GeoJSON needs [lng, lat]
339
+ const coordinates = boundary.map(([lat, lng]) => [lng, lat]);
340
+ coordinates.push(coordinates[0]); // close the ring
341
+ return {
342
+ type: "Feature",
343
+ properties: {
344
+ h3_index: cellId,
345
+ resolution: h3.getResolution(cellId)
346
+ },
347
+ geometry: {
348
+ type: "Polygon",
349
+ coordinates: [coordinates]
350
+ }
351
+ };
352
+ });
353
+ return {
354
+ type: "FeatureCollection",
355
+ features
356
+ };
357
+ }
358
+ async function geocodeLocation(query) {
359
+ if (!MAPBOX_ACCESS_TOKEN) {
360
+ throw new Error('Mapbox API token not configured. Set MAPBOX_ACCESS_TOKEN environment variable to use the location search feature. Alternatively, provide exact coordinate bounds via the `bbox` argument.');
361
+ }
362
+ const controller = new AbortController();
363
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
364
+ try {
365
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=1`;
366
+ const response = await fetch(url, { signal: controller.signal });
367
+ if (!response.ok) {
368
+ throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
369
+ }
370
+ const data = await response.json();
371
+ if (!data || typeof data !== "object" || !Array.isArray(data.features)) {
372
+ throw new Error(`Unexpected geocoding response format from Mapbox API for query: ${query}`);
373
+ }
374
+ if (!data.features || data.features.length === 0) {
375
+ throw new Error(`No results found for location: ${query}`);
376
+ }
377
+ const feature = data.features[0];
378
+ if (feature.bbox) {
379
+ // mb bbox format: minX, minY, maxX, maxY
380
+ return {
381
+ lat1: feature.bbox[1],
382
+ lng1: feature.bbox[0],
383
+ lat2: feature.bbox[3],
384
+ lng2: feature.bbox[2]
385
+ };
386
+ }
387
+ else if (feature.center) {
388
+ // create a small bbox around the center point (10km roughly)
389
+ const centerLng = feature.center[0];
390
+ const centerLat = feature.center[1];
391
+ const offset = 0.05;
392
+ return {
393
+ lat1: centerLat - offset,
394
+ lng1: centerLng - offset,
395
+ lat2: centerLat + offset,
396
+ lng2: centerLng + offset
397
+ };
398
+ }
399
+ else {
400
+ throw new Error(`Unable to determine bounding box for location: ${query}`);
401
+ }
402
+ }
403
+ catch (err) {
404
+ if (err.name === 'AbortError') {
405
+ throw new Error(`Geocoding request timed out after ${FETCH_TIMEOUT_MS / 1000}s for query: ${query}`);
406
+ }
407
+ throw err;
408
+ }
409
+ finally {
410
+ clearTimeout(timeout);
411
+ }
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // Tool Handlers
415
+ // ---------------------------------------------------------------------------
416
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
417
+ return {
418
+ tools: [
419
+ {
420
+ name: "get_bounds",
421
+ description: "Get converted coordinates for a bounding box or text location search. Supports parsing WKT, GeoJSON, ogrinfo extent, and raw coordinate strings. If MAPBOX_ACCESS_TOKEN is not set, you MUST provide explicit coordinates via 'bbox'.",
422
+ inputSchema: {
423
+ type: "object",
424
+ properties: {
425
+ location: {
426
+ type: "string",
427
+ description: "A text location to search for (e.g. 'New York City'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
428
+ },
429
+ bbox: {
430
+ type: "string",
431
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), a WKT polygon/linestring/point, a GeoJSON payload, or an `ogrinfo` extent block. The Minimum Bounding Rectangle (MBR) encapsulating the geometry will be extracted. Either 'location' or 'bbox' MUST be provided."
432
+ },
433
+ epsg: {
434
+ type: "string",
435
+ description: "The projected EPSG code (e.g. '3857'). Defaults to '4326' (WGS84). Over 3,900 bundled projections; unknown codes are auto-fetched from epsg.io.",
436
+ },
437
+ format: {
438
+ type: "string",
439
+ description: "Format of the output. Options: wkt, geojson-bbox, leaflet, overpass, ogc-bbox, kml, geojson-polygon, csv, stac-bbox. Default: csv.",
440
+ enum: ["wkt", "geojson-bbox", "leaflet", "overpass", "ogc-bbox", "kml", "geojson-polygon", "csv", "stac-bbox"]
441
+ },
442
+ precision: {
443
+ type: "number",
444
+ description: "The number of decimal places to output coordinate strings as (defaults to 6)."
445
+ }
446
+ },
447
+ oneOf: [
448
+ { required: ["location"] },
449
+ { required: ["bbox"] }
450
+ ]
451
+ },
452
+ },
453
+ {
454
+ name: "get_h3_indices",
455
+ description: "Get Uber H3 cell indices for a bounding box area at a target resolution. Input supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings.",
456
+ inputSchema: {
457
+ type: "object",
458
+ properties: {
459
+ location: {
460
+ type: "string",
461
+ description: "A text location to search for (e.g. 'San Francisco'). Requires MAPBOX_ACCESS_TOKEN env var. Either 'location' or 'bbox' MUST be provided."
462
+ },
463
+ bbox: {
464
+ type: "string",
465
+ 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.",
466
+ },
467
+ resolution: {
468
+ type: "number",
469
+ description: "The H3 resolution level, from 0 to 15. (e.g. 7)"
470
+ },
471
+ compact: {
472
+ type: "boolean",
473
+ description: "Whether to return a compacted list of cells (mixing resolutions) to minimize response size. Defaults to false."
474
+ },
475
+ return_geometry: {
476
+ type: "boolean",
477
+ description: "If true, return GeoJSON FeatureCollection with hex boundary polygons in addition to cell IDs. Defaults to false."
478
+ }
479
+ },
480
+ required: ["resolution"],
481
+ oneOf: [
482
+ { required: ["location"] },
483
+ { required: ["bbox"] }
484
+ ]
485
+ },
486
+ },
487
+ {
488
+ name: "generate_share_url",
489
+ description: "Generates a URL that links to the visual Bounding Box tool to display these coordinates on a map. Supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings as input.",
490
+ inputSchema: {
491
+ type: "object",
492
+ properties: {
493
+ bbox: {
494
+ type: "string",
495
+ description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent.",
496
+ }
497
+ },
498
+ required: ["bbox"],
499
+ },
500
+ }
501
+ ],
502
+ };
503
+ });
504
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
505
+ const toolName = request.params.name;
506
+ log('info', `Tool called: ${toolName}`, { arguments: request.params.arguments });
507
+ try {
508
+ if (toolName === "get_bounds") {
509
+ const args = request.params.arguments || {};
510
+ let bboxObj;
511
+ if (args.location) {
512
+ try {
513
+ bboxObj = await geocodeLocation(args.location);
514
+ }
515
+ catch (err) {
516
+ if (!args.bbox) {
517
+ log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
518
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
519
+ }
520
+ // User provided bbox fallback
521
+ log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
522
+ bboxObj = parseBBox(args.bbox);
523
+ }
524
+ }
525
+ else if (args.bbox) {
526
+ bboxObj = parseBBox(args.bbox);
527
+ }
528
+ else {
529
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
530
+ }
531
+ let epsg = args.epsg || "4326";
532
+ let format = args.format || "csv";
533
+ let precision = args.precision || 6;
534
+ epsg = epsg.replace(/[^0-9]/g, "");
535
+ // Dynamically fetch projection if not bundled
536
+ await ensureProjection(epsg);
537
+ const projCoords = projectBounds(bboxObj, epsg);
538
+ const WGS84Formatted = getFormattedBox(bboxObj, format, precision);
539
+ // Build projected bbox for formatting (uses projected values)
540
+ const projBbox = {
541
+ lat1: projCoords.ymin,
542
+ lng1: projCoords.xmin,
543
+ lat2: projCoords.ymax,
544
+ lng2: projCoords.xmax
545
+ };
546
+ const projectedFormatted = getFormattedBox(projBbox, format, precision);
547
+ const shareUrl = buildShareUrl(bboxObj);
548
+ const dims = calculateBboxDimensions(bboxObj);
549
+ const areaKm2 = calculateBboxArea(bboxObj);
550
+ log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
551
+ return {
552
+ content: [
553
+ {
554
+ type: "text",
555
+ text: `Original WGS84 Bounds (${format}):\n${WGS84Formatted}\n\nProjected to EPSG:${epsg} (${format}):\n${projectedFormatted}\n\nArea: ${areaKm2.toFixed(3)} km² (${dims.widthKm.toFixed(3)}km x ${dims.heightKm.toFixed(3)}km)\n\nView on map: ${shareUrl}`
556
+ },
557
+ {
558
+ type: "text",
559
+ text: JSON.stringify({
560
+ original_wgs84: bboxObj,
561
+ projected: projCoords,
562
+ epsg,
563
+ format,
564
+ precision,
565
+ area_km2: areaKm2,
566
+ dimensions: {
567
+ width_km: dims.widthKm,
568
+ height_km: dims.heightKm
569
+ },
570
+ share_url: shareUrl
571
+ }, null, 2)
572
+ }
573
+ ]
574
+ };
575
+ }
576
+ else if (toolName === "get_h3_indices") {
577
+ const args = request.params.arguments || {};
578
+ const resolution = args.resolution;
579
+ const compact = args.compact || false;
580
+ const returnGeometry = args.return_geometry || false;
581
+ if (resolution === undefined) {
582
+ return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
583
+ }
584
+ let bboxObj;
585
+ if (args.location) {
586
+ try {
587
+ bboxObj = await geocodeLocation(args.location);
588
+ }
589
+ catch (err) {
590
+ if (!args.bbox) {
591
+ log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
592
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
593
+ }
594
+ bboxObj = parseBBox(args.bbox);
595
+ }
596
+ }
597
+ else if (args.bbox) {
598
+ bboxObj = parseBBox(args.bbox);
599
+ }
600
+ else {
601
+ return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
602
+ }
603
+ const cells = getH3Cells(bboxObj, resolution, compact);
604
+ const shareUrl = buildShareUrl(bboxObj);
605
+ log('info', `get_h3_indices completed`, { resolution, compact, cell_count: cells.length });
606
+ const responseData = {
607
+ resolution,
608
+ compact,
609
+ cell_count: cells.length,
610
+ cells,
611
+ original_bbox: bboxObj,
612
+ share_url: shareUrl
613
+ };
614
+ if (returnGeometry) {
615
+ responseData.geojson = h3CellsToGeoJSON(cells);
616
+ }
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ 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}`
622
+ },
623
+ {
624
+ type: "text",
625
+ text: JSON.stringify(responseData, null, 2)
626
+ }
627
+ ]
628
+ };
629
+ }
630
+ else if (toolName === "generate_share_url") {
631
+ const args = request.params.arguments || {};
632
+ const bbox = args.bbox;
633
+ if (!bbox) {
634
+ return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
635
+ }
636
+ const bboxObj = parseBBox(bbox);
637
+ const shareUrl = buildShareUrl(bboxObj);
638
+ log('info', `generate_share_url completed`, { share_url: shareUrl });
639
+ return {
640
+ content: [
641
+ {
642
+ type: "text",
643
+ text: `View on map: ${shareUrl}\nNote: you can paste this URL directly in your browser.`
644
+ },
645
+ {
646
+ type: "text",
647
+ text: JSON.stringify({
648
+ bbox: bboxObj,
649
+ share_url: shareUrl
650
+ }, null, 2)
651
+ }
652
+ ]
653
+ };
654
+ }
655
+ else {
656
+ log('warn', `Unknown tool requested: ${toolName}`);
657
+ return {
658
+ content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
659
+ isError: true,
660
+ };
661
+ }
662
+ }
663
+ catch (error) {
664
+ log('error', `Tool execution error in ${toolName}`, { error: error.message, stack: error.stack });
665
+ return {
666
+ content: [{ type: "text", text: `Tool Execution Error: ${error.message}` }],
667
+ isError: true,
668
+ };
669
+ }
670
+ });
671
+ // ---------------------------------------------------------------------------
672
+ // Server Startup
673
+ // ---------------------------------------------------------------------------
674
+ async function run() {
675
+ const transport = new StdioServerTransport();
676
+ await server.connect(transport);
677
+ log('info', `Server started`, { version: SERVER_VERSION, max_h3_cells: MAX_H3_CELLS, mapbox_configured: !!MAPBOX_ACCESS_TOKEN });
678
+ process.on("SIGINT", async () => {
679
+ log('info', 'Received SIGINT, shutting down');
680
+ await server.close();
681
+ process.exit(0);
682
+ });
683
+ process.on("SIGTERM", async () => {
684
+ log('info', 'Received SIGTERM, shutting down');
685
+ await server.close();
686
+ process.exit(0);
687
+ });
688
+ }
689
+ run().catch((err) => {
690
+ log('error', 'Server startup failed', { error: err.message, stack: err.stack });
691
+ process.exit(1);
692
+ });