geotap-mcp-server 1.2.1 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geotap-mcp-server",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for GeoTap — access 37 US federal environmental and infrastructure data sources from Claude, Cursor, and other AI tools",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -9,7 +9,10 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
- "dev": "node --watch src/index.js"
12
+ "dev": "node --watch src/index.js",
13
+ "test": "node tests/unit-tests.js",
14
+ "test:verbose": "node tests/unit-tests.js --verbose",
15
+ "test:integration": "node tests/workflow-tests.js"
13
16
  },
14
17
  "keywords": [
15
18
  "mcp",
package/src/api.js CHANGED
@@ -1,6 +1,95 @@
1
1
  const BASE_URL = process.env.GEOTAP_API_URL || 'https://geotapdata.com/api/v1';
2
2
  const API_KEY = process.env.GEOTAP_API_KEY || '';
3
3
 
4
+ /**
5
+ * Structured API Error with fix instructions for LLMs.
6
+ */
7
+ export class StructuredApiError extends Error {
8
+ constructor(details) {
9
+ super(details.message);
10
+ this.name = 'StructuredApiError';
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Build a structured error with fix instructions and related tools.
17
+ */
18
+ function buildStructuredError(status, errorText, endpoint, method, params) {
19
+ const base = {
20
+ error: true,
21
+ status,
22
+ message: `GeoTap API error (${status}): ${errorText}`,
23
+ endpoint,
24
+ method
25
+ };
26
+
27
+ // Parse common error patterns and provide actionable fixes
28
+ const fixes = [];
29
+ const relatedTools = [];
30
+
31
+ if (status === 400) {
32
+ if (/geometry|polygon|geojson/i.test(errorText)) {
33
+ fixes.push('Provide a valid GeoJSON geometry object, or use lat/lng parameters instead (they will be auto-converted to GeoJSON).');
34
+ fixes.push('Example: { "lat": 32.08, "lng": -81.09 } instead of a GeoJSON polygon.');
35
+ }
36
+ if (/missing|required/i.test(errorText)) {
37
+ const missingParam = errorText.match(/(?:missing|required)[:\s]+(\w+)/i)?.[1];
38
+ if (missingParam) {
39
+ fixes.push(`Add the required parameter: "${missingParam}".`);
40
+ }
41
+ }
42
+ if (/lat|lng|lon|coordinate/i.test(errorText)) {
43
+ fixes.push('Ensure lat is between -90 and 90, lng is between -180 and 180. Note: coordinates must be within the United States.');
44
+ relatedTools.push('geocode_address');
45
+ }
46
+ if (/layer/i.test(errorText)) {
47
+ fixes.push('Use list_data_layers to see valid layer names. Layer names use underscores (e.g., flood_zones, wetlands).');
48
+ relatedTools.push('list_data_layers');
49
+ }
50
+ if (/bbox|bounding/i.test(errorText)) {
51
+ fixes.push('Bounding box format: "west,south,east,north" in WGS84 (e.g., "-81.1,32.0,-81.0,32.1"). West must be less than east, south less than north.');
52
+ }
53
+ }
54
+
55
+ if (status === 401 || status === 403) {
56
+ fixes.push('Set GEOTAP_API_KEY environment variable with a valid API key. Get one at https://geotapdata.com');
57
+ relatedTools.push('check_api_status');
58
+ }
59
+
60
+ if (status === 404) {
61
+ if (/gage|site|station/i.test(endpoint)) {
62
+ fixes.push('Station/gage not found. Verify the site ID is a valid USGS station number (e.g., "08158000").');
63
+ relatedTools.push('find_monitoring_stations', 'search_stations');
64
+ }
65
+ if (/job/i.test(endpoint)) {
66
+ fixes.push('Job ID not found or expired. Submit a new request to start a fresh job.');
67
+ }
68
+ }
69
+
70
+ if (status === 429) {
71
+ fixes.push('Rate limit exceeded. Wait a moment and retry, or upgrade your API key tier at https://geotapdata.com');
72
+ }
73
+
74
+ if (status >= 500) {
75
+ fixes.push('Server error — the upstream federal data source may be temporarily unavailable.');
76
+ fixes.push('Try again in a few seconds, or check check_api_status to see which services are up.');
77
+ relatedTools.push('check_api_status');
78
+ }
79
+
80
+ if (fixes.length === 0) {
81
+ fixes.push('Check that all required parameters are provided and valid.');
82
+ fixes.push('Use discover_tools to find the right tool for your question.');
83
+ relatedTools.push('discover_tools');
84
+ }
85
+
86
+ return {
87
+ ...base,
88
+ fix: fixes,
89
+ relatedTools: [...new Set(relatedTools)]
90
+ };
91
+ }
92
+
4
93
  /**
5
94
  * Call the GeoTap API.
6
95
  * Handles both GET (query params) and POST (JSON body) requests.
@@ -52,7 +141,8 @@ export async function callApi(endpoint, method, params) {
52
141
 
53
142
  if (!response.ok) {
54
143
  const errorText = await response.text().catch(() => 'Unknown error');
55
- throw new Error(`GeoTap API error (${response.status}): ${errorText}`);
144
+ const structured = buildStructuredError(response.status, errorText, endpoint, method, params);
145
+ throw new StructuredApiError(structured);
56
146
  }
57
147
 
58
148
  // Check content type to handle binary vs JSON responses
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tool Discovery Meta-Tool
3
+ *
4
+ * With 68+ tools, LLMs waste context loading all descriptions upfront.
5
+ * This meta-tool lets the AI describe what it needs in natural language
6
+ * and get back the 3-5 most relevant tools.
7
+ */
8
+
9
+ import { tools } from './tools.js';
10
+
11
+ /**
12
+ * Tool categories with keywords for matching.
13
+ */
14
+ const TOOL_CATEGORIES = {
15
+ 'Getting Started': {
16
+ keywords: ['start', 'begin', 'address', 'location', 'what is', 'tell me about', 'lookup', 'search address', 'find address'],
17
+ tools: ['query_address', 'identify_features_at_point', 'geocode_address']
18
+ },
19
+ 'Flood & FEMA': {
20
+ keywords: ['flood', 'fema', 'firm', 'floodplain', 'flood zone', 'flood insurance', 'nfhl', 'base flood', 'special flood'],
21
+ tools: ['query_address', 'get_firm_panels', 'get_environmental_data_for_area']
22
+ },
23
+ 'Rainfall & Precipitation': {
24
+ keywords: ['rain', 'rainfall', 'precipitation', 'storm', 'idf', 'hyetograph', 'atlas 14', 'design storm', 'intensity', 'duration', 'frequency', 'climate change', 'climate projection'],
25
+ tools: ['get_rainfall_data', 'get_idf_curves', 'generate_hyetograph', 'get_rainfall_distribution', 'get_climate_change_rainfall_projection', 'get_rainfall_uncertainty_bounds', 'run_rainfall_sensitivity_analysis']
26
+ },
27
+ 'Watershed & Hydrology': {
28
+ keywords: ['watershed', 'drainage', 'basin', 'delineate', 'hydrology', 'runoff', 'peak flow', 'streamstats', 'catchment', 'time of concentration'],
29
+ tools: ['delineate_watershed', 'get_watershed_characteristics', 'get_flow_statistics', 'analyze_hydrology', 'get_flowlines']
30
+ },
31
+ 'Curve Number & Soils': {
32
+ keywords: ['curve number', 'cn', 'soil', 'hsg', 'hydrologic soil', 'runoff', 'scs', 'nrcs', 'ssurgo', 'infiltration', 'pervious', 'impervious'],
33
+ tools: ['analyze_curve_numbers', 'lookup_curve_number', 'get_curve_number_tables']
34
+ },
35
+ 'Water Quality': {
36
+ keywords: ['water quality', 'impairment', 'impaired', 'pollution', 'pollutant', 'tmdl', '303d', 'attains', 'epa', 'clean water', 'npdes', 'discharge'],
37
+ tools: ['get_water_quality', 'get_water_impairments', 'get_watershed_for_point', 'get_watershed_water_quality']
38
+ },
39
+ 'Wetlands & Species': {
40
+ keywords: ['wetland', 'nwi', 'endangered', 'species', 'habitat', 'critical habitat', 'protected', 'conservation', 'wildlife'],
41
+ tools: ['query_address', 'identify_features_at_point', 'get_environmental_data_for_area']
42
+ },
43
+ 'Elevation & Terrain': {
44
+ keywords: ['elevation', 'dem', 'terrain', 'contour', 'topography', 'slope', 'lidar', '3dep', 'relief', 'grading'],
45
+ tools: ['get_elevation_stats', 'get_contour_lines', 'export_dem', 'export_contours', 'check_dem_availability']
46
+ },
47
+ 'Stream Gages': {
48
+ keywords: ['gage', 'gauge', 'stream gage', 'streamflow', 'usgs gage', 'flood frequency', 'flow duration', 'low flow', 'storm event', 'bulletin 17', 'peak flow record', 'annual peak'],
49
+ tools: ['get_gage_summary', 'get_flood_frequency_analysis', 'get_flow_duration_curve', 'get_low_flow_statistics', 'get_storm_events', 'get_published_gage_statistics']
50
+ },
51
+ 'Ungaged Estimation': {
52
+ keywords: ['ungaged', 'ungauged', 'no gage', 'regression', 'nss', 'regional equation', 'estimate flow', 'similar watershed'],
53
+ tools: ['estimate_ungaged_flood_frequency', 'estimate_all_ungaged_statistics', 'find_similar_watersheds', 'recommend_index_gage', 'transfer_flood_statistics']
54
+ },
55
+ 'Permits & Regulatory': {
56
+ keywords: ['permit', 'regulatory', 'section 404', 'usace', 'army corps', 'waterway', 'crossing', 'construction near water', '401 certification'],
57
+ tools: ['find_water_features', 'analyze_permit_requirements']
58
+ },
59
+ 'Site Analysis & Reports': {
60
+ keywords: ['site analysis', 'report', 'assessment', 'due diligence', 'developability', 'constraints', 'can i build', 'feasibility', 'environmental review'],
61
+ tools: ['generate_site_analysis', 'generate_constraints_report', 'generate_developability_report']
62
+ },
63
+ 'Monitoring Stations': {
64
+ keywords: ['station', 'monitoring', 'weather station', 'tide', 'groundwater', 'well', 'camera', 'sensor'],
65
+ tools: ['find_monitoring_stations', 'search_stations', 'get_station_types']
66
+ },
67
+ 'Data Export': {
68
+ keywords: ['export', 'download', 'shapefile', 'geojson', 'csv', 'kml', 'geopackage', 'geotiff', 'gis', 'cad'],
69
+ tools: ['export_data', 'get_export_options', 'export_dem', 'export_contours', 'export_land_use', 'export_satellite_imagery']
70
+ },
71
+ 'Land Use & Imagery': {
72
+ keywords: ['land use', 'land cover', 'nlcd', 'satellite', 'imagery', 'aerial', 'naip', 'impervious surface', 'developed', 'forest'],
73
+ tools: ['export_land_use', 'export_satellite_imagery', 'get_satellite_resolution_options']
74
+ },
75
+ 'HUC Watersheds': {
76
+ keywords: ['huc', 'hydrologic unit', 'watershed boundary', 'wbd', 'huc8', 'huc10', 'huc12', 'subwatershed'],
77
+ tools: ['get_huc_watersheds', 'get_huc_watershed_by_code', 'get_watershed_for_point']
78
+ },
79
+ 'API Status': {
80
+ keywords: ['status', 'health', 'api', 'available', 'working', 'service', 'down', 'outage'],
81
+ tools: ['check_api_status', 'check_specific_api_status', 'check_rainfall_service_status']
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Find the most relevant tools for a natural language question.
87
+ */
88
+ export function discoverTools(question, maxResults = 5) {
89
+ const q = question.toLowerCase();
90
+ const scores = {};
91
+
92
+ // Score each category by keyword matches
93
+ for (const [category, config] of Object.entries(TOOL_CATEGORIES)) {
94
+ let score = 0;
95
+ for (const keyword of config.keywords) {
96
+ if (q.includes(keyword)) {
97
+ score += keyword.split(' ').length; // Multi-word matches score higher
98
+ }
99
+ }
100
+ if (score > 0) {
101
+ for (const toolName of config.tools) {
102
+ scores[toolName] = (scores[toolName] || 0) + score;
103
+ }
104
+ }
105
+ }
106
+
107
+ // Also score by tool description similarity
108
+ for (const tool of tools) {
109
+ const desc = tool.description.toLowerCase();
110
+ const words = q.split(/\s+/).filter(w => w.length > 3);
111
+ let descScore = 0;
112
+ for (const word of words) {
113
+ if (desc.includes(word)) descScore += 0.5;
114
+ }
115
+ if (descScore > 0) {
116
+ scores[tool.name] = (scores[tool.name] || 0) + descScore;
117
+ }
118
+ }
119
+
120
+ // Sort by score and return top N
121
+ const ranked = Object.entries(scores)
122
+ .sort((a, b) => b[1] - a[1])
123
+ .slice(0, maxResults);
124
+
125
+ // Build result with tool details
126
+ const toolMap = new Map(tools.map(t => [t.name, t]));
127
+ const results = ranked.map(([name, score]) => {
128
+ const tool = toolMap.get(name);
129
+ if (!tool) return null;
130
+
131
+ // Build parameter info
132
+ const params = {};
133
+ if (tool.parameters) {
134
+ for (const [pName, pSchema] of Object.entries(tool.parameters)) {
135
+ params[pName] = {
136
+ type: pSchema._def?.typeName || 'unknown',
137
+ required: !pSchema.isOptional?.(),
138
+ description: pSchema._def?.description || pSchema.description || ''
139
+ };
140
+ }
141
+ }
142
+
143
+ return {
144
+ name: tool.name,
145
+ description: tool.description.split('.')[0] + '.', // First sentence only
146
+ method: tool.method,
147
+ parameters: params,
148
+ relevanceScore: Math.round(score * 10) / 10
149
+ };
150
+ }).filter(Boolean);
151
+
152
+ // Find matching categories
153
+ const matchedCategories = [];
154
+ for (const [category, config] of Object.entries(TOOL_CATEGORIES)) {
155
+ let score = 0;
156
+ for (const keyword of config.keywords) {
157
+ if (q.includes(keyword)) score++;
158
+ }
159
+ if (score > 0) matchedCategories.push({ category, relevance: score });
160
+ }
161
+ matchedCategories.sort((a, b) => b.relevance - a.relevance);
162
+
163
+ return {
164
+ question,
165
+ recommendedTools: results,
166
+ matchedCategories: matchedCategories.slice(0, 3).map(c => c.category),
167
+ allCategories: Object.keys(TOOL_CATEGORIES),
168
+ hint: results.length > 0
169
+ ? `Start with "${results[0].name}" — it's the best match for your question.`
170
+ : 'No strong matches found. Try query_address for location-based questions, or list_data_layers to see available data.',
171
+ totalToolsAvailable: tools.length
172
+ };
173
+ }
package/src/index.js CHANGED
@@ -4,12 +4,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { z } from 'zod';
6
6
  import { tools } from './tools.js';
7
- import { callApi } from './api.js';
7
+ import { callApi, StructuredApiError } from './api.js';
8
8
  import { toolSources } from './sources.js';
9
+ import { capResponse } from './responseCap.js';
10
+ import { generateSummary } from './summaries.js';
11
+ import { convertLatLng } from './latLngHelper.js';
12
+ import { discoverTools } from './discoverTools.js';
13
+ import { readFileSync } from 'fs';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, join } from 'path';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
18
 
10
19
  const server = new McpServer({
11
20
  name: 'geotap',
12
- version: '1.2.0',
21
+ version: '1.3.0',
13
22
  description: 'Access 37 US federal environmental and infrastructure data sources. Query flood zones, wetlands, soils, rainfall, watersheds, water quality, endangered species, elevation, land use, and more for any location in the United States.',
14
23
  instructions: `You have access to GeoTap, which provides real-time data from 37 US federal agencies (FEMA, USGS, NOAA, EPA, NRCS, USFWS, USACE, and more).
15
24
 
@@ -18,15 +27,25 @@ START HERE — CORE TOOLS (use these for 90% of queries):
18
27
  2. identify_features_at_point — Same as above but when you already have lat/lng coordinates.
19
28
  3. get_rainfall_data — NOAA Atlas 14 precipitation data for any US location.
20
29
  4. get_environmental_summary — Quick feature counts for an area (no geometry, just numbers).
21
- 5. geocode_addressConvert address to coordinates (only needed if query_address doesn't cover your use case).
30
+ 5. discover_toolsDon't know which tool to use? Describe your question and get the best matches.
22
31
 
23
- These 5 tools handle most questions. Use specialized tools only when these don't cover the request.
32
+ NEW IN v1.3: SMART FEATURES:
33
+ - Every response includes a _summary field with a plain-English description.
34
+ - POST endpoints accept flat lat/lng parameters — no need to construct GeoJSON.
35
+ - Responses are automatically capped to prevent context window overflow.
36
+ - Structured error messages tell you exactly how to fix issues.
37
+ - Use discover_tools to find the right tool from 68+ options.
24
38
 
25
39
  RESPONSE SIZE MANAGEMENT:
26
40
  - query_address and identify_features_at_point always return <5KB (no geometry, just properties + interpretations).
27
41
  - For all other spatial tools, ALWAYS set geometry="none" unless the user specifically needs coordinates.
28
42
  - Always specify layers (e.g., layers="flood_zones,wetlands") instead of querying all 19 layers.
29
- - In urban areas, full-geometry responses can exceed 1MB. Geometry="none" reduces this to <10KB.
43
+ - Responses are now auto-capped at 50 features per layer with summaries.
44
+
45
+ LAT/LNG SHORTCUT:
46
+ - POST tools that normally require GeoJSON now accept { lat, lng } instead.
47
+ - Example: generate_site_analysis({ lat: 32.08, lng: -81.09 }) — auto-converts to GeoJSON Point.
48
+ - For polygon tools, lat/lng creates a ~0.5km bounding box around the point.
30
49
 
31
50
  COMMON WORKFLOWS:
32
51
  - "What flood zone is this address in?" → query_address (one call, done)
@@ -38,14 +57,52 @@ COMMON WORKFLOWS:
38
57
 
39
58
  IMPORTANT NOTES:
40
59
  - All data comes from authoritative federal sources. Always mention the source agency.
41
- - Responses include _interpretation fields with plain-English summaries — use these in your answers.
60
+ - Responses include _summary with plain-English summaries — use these in your answers.
61
+ - Responses include _interpretation fields with per-feature context — reference these too.
42
62
  - This data is for informational purposes. Remind users to verify critical data for engineering/regulatory decisions.
43
63
  - Coordinates must be within the United States (including territories).
44
64
  - Some tools (watershed delineation, hydrology) can take 10-60 seconds.
45
65
  - Layer names use underscores: flood_zones, wetlands, dem_elevation, building_footprints, etc.`
46
66
  });
47
67
 
48
- // Register all tools
68
+ // ── Register discover_tools meta-tool ───────────────────────────────
69
+
70
+ server.tool(
71
+ 'discover_tools',
72
+ 'Find the best GeoTap tools for your question. Describe what you need in plain English and get back the 3-5 most relevant tools with their parameters. Use this when you have 68 tools and aren\'t sure which one to pick.',
73
+ {
74
+ question: z.string().describe('Natural language description of what you want to do (e.g., "What flood zone is this property in?" or "I need rainfall data for stormwater design")'),
75
+ maxResults: z.number().optional().describe('Maximum tools to return (default: 5)')
76
+ },
77
+ async (params) => {
78
+ const result = discoverTools(params.question, params.maxResults || 5);
79
+ return {
80
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
81
+ };
82
+ }
83
+ );
84
+
85
+ // ── Register get_llms_txt tool ──────────────────────────────────────
86
+
87
+ server.tool(
88
+ 'get_llms_txt',
89
+ 'Get the GeoTap API discovery document (llms.txt). Returns a structured description of all API endpoints, data sources, and usage tips optimized for AI agents. Use this to understand the full API before making queries.',
90
+ {},
91
+ async () => {
92
+ try {
93
+ const content = readFileSync(join(__dirname, 'llms.txt'), 'utf-8');
94
+ return { content: [{ type: 'text', text: content }] };
95
+ } catch {
96
+ return {
97
+ content: [{ type: 'text', text: 'llms.txt not found. Visit https://geotapdata.com/llms.txt for API documentation.' }],
98
+ isError: true
99
+ };
100
+ }
101
+ }
102
+ );
103
+
104
+ // ── Register all API tools ──────────────────────────────────────────
105
+
49
106
  for (const tool of tools) {
50
107
  server.tool(
51
108
  tool.name,
@@ -53,12 +110,28 @@ for (const tool of tools) {
53
110
  tool.parameters,
54
111
  async (params) => {
55
112
  try {
56
- const result = await callApi(tool.endpoint, tool.method, params);
113
+ // Improvement #3: Convert lat/lng to GeoJSON if needed
114
+ const convertedParams = convertLatLng(tool.name, params);
115
+
116
+ // Strip internal fields before sending to API
117
+ const apiParams = { ...convertedParams };
118
+ delete apiParams._latLngConverted;
119
+
120
+ const rawResult = await callApi(tool.endpoint, tool.method, apiParams);
57
121
 
58
- // Enrich response with source attribution and data freshness
122
+ // Improvement #1: Cap response size
123
+ const { data: cappedResult, wasCapped, capInfo } = capResponse(tool.name, rawResult);
124
+
125
+ // Improvement #2: Generate natural language summary
126
+ const summary = generateSummary(tool.name, params, cappedResult);
127
+
128
+ // Enrich response with source attribution, summary, and metadata
59
129
  const sources = toolSources[tool.name] || [];
60
130
  const enriched = {
61
- ...result,
131
+ ...(summary ? { _summary: summary } : {}),
132
+ ...cappedResult,
133
+ ...(convertedParams._latLngConverted ? { _latLngConverted: convertedParams._latLngConverted } : {}),
134
+ ...(wasCapped ? { _responseCapped: capInfo } : {}),
62
135
  _meta: {
63
136
  sources,
64
137
  retrievedAt: new Date().toISOString(),
@@ -70,8 +143,21 @@ for (const tool of tools) {
70
143
  content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }]
71
144
  };
72
145
  } catch (error) {
146
+ // Improvement #5: Structured error messages
147
+ if (error instanceof StructuredApiError) {
148
+ return {
149
+ content: [{ type: 'text', text: JSON.stringify(error.details, null, 2) }],
150
+ isError: true
151
+ };
152
+ }
153
+
73
154
  return {
74
- content: [{ type: 'text', text: `Error: ${error.message}` }],
155
+ content: [{ type: 'text', text: JSON.stringify({
156
+ error: true,
157
+ message: error.message,
158
+ fix: ['Check that all required parameters are provided and valid.', 'Use discover_tools to find the right tool for your question.'],
159
+ relatedTools: ['discover_tools', 'check_api_status']
160
+ }, null, 2) }],
75
161
  isError: true
76
162
  };
77
163
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Lat/Lng Convenience Helper
3
+ *
4
+ * Automatically converts flat lat/lng parameters to GeoJSON geometry
5
+ * for POST endpoints that require GeoJSON input. This eliminates the
6
+ * #1 error pattern where LLMs get GeoJSON wrong (especially lng/lat order).
7
+ */
8
+
9
+ /**
10
+ * Tools that accept geometry/polygon/location as GeoJSON but could
11
+ * accept lat/lng instead for point-based queries.
12
+ */
13
+ const GEOMETRY_TOOLS = {
14
+ // Tools that accept 'polygon' parameter — convert lat/lng to small bounding box
15
+ get_environmental_data_for_area: { param: 'polygon', type: 'bbox' },
16
+ get_environmental_summary: { param: 'polygon', type: 'bbox' },
17
+ find_water_features: { param: 'polygon', type: 'bbox' },
18
+ export_dem: { param: 'polygon', type: 'bbox' },
19
+ export_contours: { param: 'polygon', type: 'bbox' },
20
+ export_land_use: { param: 'polygon', type: 'bbox' },
21
+ export_satellite_imagery: { param: 'polygon', type: 'bbox' },
22
+
23
+ // Tools that accept 'geometry' parameter — convert to Point
24
+ generate_site_analysis: { param: 'geometry', type: 'point' },
25
+ generate_constraints_report: { param: 'geometry', type: 'point' },
26
+ generate_developability_report: { param: 'geometry', type: 'point' },
27
+ export_data: { param: 'geometry', type: 'point' },
28
+
29
+ // Tools that accept 'location' parameter — convert to Point
30
+ get_water_quality: { param: 'location', type: 'point' },
31
+
32
+ // Tools that accept 'catchments' parameter — convert to small polygon FeatureCollection
33
+ analyze_hydrology: { param: 'catchments', type: 'featureCollection' },
34
+ analyze_curve_numbers: { param: 'catchments', type: 'featureCollection' },
35
+ };
36
+
37
+ /** Default radius in km for converting a point to a small bounding box */
38
+ const DEFAULT_RADIUS_KM = 0.5;
39
+
40
+ /**
41
+ * If the tool accepts GeoJSON and the user provided lat/lng instead,
42
+ * convert to the appropriate GeoJSON structure.
43
+ *
44
+ * Returns the (possibly modified) params object.
45
+ */
46
+ export function convertLatLng(toolName, params) {
47
+ if (!params) return params;
48
+
49
+ const config = GEOMETRY_TOOLS[toolName];
50
+ if (!config) return params;
51
+
52
+ // Only convert if lat/lng are present AND the geometry param is missing
53
+ const hasLatLng = params.lat !== undefined && (params.lng !== undefined || params.lon !== undefined);
54
+ const hasGeometry = params[config.param] !== undefined;
55
+
56
+ if (!hasLatLng || hasGeometry) return params;
57
+
58
+ const lat = Number(params.lat);
59
+ const lng = Number(params.lng ?? params.lon);
60
+
61
+ if (isNaN(lat) || isNaN(lng)) return params;
62
+
63
+ const newParams = { ...params };
64
+ delete newParams.lat;
65
+ delete newParams.lng;
66
+ delete newParams.lon;
67
+
68
+ const radiusKm = params.radius || DEFAULT_RADIUS_KM;
69
+
70
+ switch (config.type) {
71
+ case 'point':
72
+ newParams[config.param] = {
73
+ type: 'Point',
74
+ coordinates: [lng, lat]
75
+ };
76
+ break;
77
+
78
+ case 'bbox': {
79
+ const bbox = createBBox(lat, lng, radiusKm);
80
+ newParams[config.param] = {
81
+ type: 'Polygon',
82
+ coordinates: [bbox]
83
+ };
84
+ break;
85
+ }
86
+
87
+ case 'featureCollection': {
88
+ const bbox = createBBox(lat, lng, radiusKm);
89
+ newParams[config.param] = {
90
+ type: 'FeatureCollection',
91
+ features: [{
92
+ type: 'Feature',
93
+ properties: { name: 'Area of Interest' },
94
+ geometry: {
95
+ type: 'Polygon',
96
+ coordinates: [bbox]
97
+ }
98
+ }]
99
+ };
100
+ break;
101
+ }
102
+ }
103
+
104
+ // Add a note so the LLM knows what happened
105
+ newParams._latLngConverted = {
106
+ originalLat: lat,
107
+ originalLng: lng,
108
+ convertedTo: config.type,
109
+ radiusKm: config.type !== 'point' ? radiusKm : undefined,
110
+ note: `Converted lat/lng to ${config.type === 'point' ? 'GeoJSON Point' : `${radiusKm}km bounding box polygon`}. For custom areas, pass a GeoJSON ${config.param} directly.`
111
+ };
112
+
113
+ return newParams;
114
+ }
115
+
116
+ /**
117
+ * Create a bounding box polygon from lat/lng and radius in km.
118
+ * Returns coordinates array for a GeoJSON Polygon ring.
119
+ */
120
+ function createBBox(lat, lng, radiusKm) {
121
+ // Approximate degrees per km
122
+ const latDeg = radiusKm / 111.32;
123
+ const lngDeg = radiusKm / (111.32 * Math.cos(lat * Math.PI / 180));
124
+
125
+ const south = lat - latDeg;
126
+ const north = lat + latDeg;
127
+ const west = lng - lngDeg;
128
+ const east = lng + lngDeg;
129
+
130
+ return [
131
+ [west, south],
132
+ [east, south],
133
+ [east, north],
134
+ [west, north],
135
+ [west, south] // close the ring
136
+ ];
137
+ }