geotap-mcp-server 1.3.0 → 2.0.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.3.0",
3
+ "version": "2.0.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",
@@ -0,0 +1,480 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Consolidated Tool Definitions — 12 smart tools replacing 85 legacy tools
5
+ *
6
+ * Each tool uses an `action` parameter to route to the correct backend endpoint.
7
+ * This dramatically improves LLM tool selection accuracy (research shows accuracy
8
+ * drops from 95% at 5 tools to <14% at 40+ tools).
9
+ *
10
+ * Legacy tools remain available via GEOTAP_LEGACY_TOOLS=true env var.
11
+ */
12
+
13
+ export const consolidatedTools = [
14
+ // ═══════════════════════════════════════════════════════════════════
15
+ // 1. QUERY LOCATION — The starting point for most queries
16
+ // ═══════════════════════════════════════════════════════════════════
17
+ {
18
+ name: 'query_location',
19
+ description: `Query environmental data for any US location. This is the primary tool — start here for most questions.
20
+
21
+ Actions:
22
+ - "address" — Geocode a US address AND query environmental data in one call. Always start here when user gives an address. Returns flood zones, wetlands, soils, habitat, contamination, with plain-English interpretations. Response <5KB.
23
+ - "point" — Same as address but when you already have lat/lng coordinates. Response <5KB.
24
+ - "nearby" — Find environmental features within a radius of a point. Set geometry="none" and specify layers to keep response small.
25
+ - "bbox" — Query features in a bounding box. Set geometry="none" and specify layers.
26
+ - "polygon" — Query features in a polygon area. Set geometry="none" and specify layers.
27
+ - "summary" — Quick feature counts for an area (no geometry, just numbers). Fastest option for "how many?" questions.
28
+ - "geocode" — Convert address/place name to coordinates. Use only when you need coordinates for other tools.
29
+ - "list_layers" — List all 37 available data layers.
30
+ - "layer_details" — Get metadata about a specific layer.
31
+ - "layer_features" — Get features from one specific layer in a bbox.`,
32
+ parameters: {
33
+ action: z.enum(['address', 'point', 'nearby', 'bbox', 'polygon', 'summary', 'geocode', 'list_layers', 'layer_details', 'layer_features'])
34
+ .describe('Which query type to perform'),
35
+ // Address actions
36
+ address: z.string().optional().describe('US street address (for action: address, geocode)'),
37
+ // Coordinate actions
38
+ lat: z.number().optional().describe('Latitude WGS84 (for action: point, nearby)'),
39
+ lng: z.number().optional().describe('Longitude WGS84 (for action: point, nearby)'),
40
+ // Area actions
41
+ bbox: z.string().optional().describe('Bounding box "west,south,east,north" (for action: bbox, layer_features)'),
42
+ polygon: z.object({
43
+ type: z.literal('Polygon'),
44
+ coordinates: z.array(z.array(z.array(z.number())))
45
+ }).optional().describe('GeoJSON Polygon (for action: polygon, summary)'),
46
+ // Filtering
47
+ layers: z.string().optional().describe('Comma-separated layer names (e.g., "flood_zones,wetlands")'),
48
+ geometry: z.enum(['none', 'simplified', 'full']).optional().describe('Geometry detail level. Default: none for MCP. Use "none" unless user specifically needs coordinates.'),
49
+ radius: z.number().optional().describe('Search radius in km for action: nearby (default: 1)'),
50
+ layerName: z.string().optional().describe('Layer identifier for action: layer_details, layer_features'),
51
+ },
52
+ _actionMap: {
53
+ address: 'query_address',
54
+ point: 'identify_features_at_point',
55
+ nearby: 'get_environmental_data_near_point',
56
+ bbox: 'get_environmental_data_in_bbox',
57
+ polygon: 'get_environmental_data_for_area',
58
+ summary: 'get_environmental_summary',
59
+ geocode: 'geocode_address',
60
+ list_layers: 'list_data_layers',
61
+ layer_details: 'get_layer_details',
62
+ layer_features: 'get_layer_features',
63
+ }
64
+ },
65
+
66
+ // ═══════════════════════════════════════════════════════════════════
67
+ // 2. RAINFALL — NOAA Atlas 14, design storms, climate projections
68
+ // ═══════════════════════════════════════════════════════════════════
69
+ {
70
+ name: 'get_rainfall',
71
+ description: `Get rainfall and precipitation data from NOAA Atlas 14 for stormwater engineering, flood analysis, and hydrologic design.
72
+
73
+ Actions:
74
+ - "atlas14" — Precipitation frequency estimates (depths & intensities for all durations/return periods). The standard rainfall data for US engineering.
75
+ - "idf" — Intensity-Duration-Frequency curve data for charting.
76
+ - "hyetograph" — Generate a design storm hyetograph (rainfall over time) for hydrologic modeling.
77
+ - "export_hyetograph" — Export hyetograph as CSV/JSON for HEC-HMS, SWMM, etc.
78
+ - "distributions" — List available rainfall distribution types (SCS Type I/IA/II/III, Huff, etc.).
79
+ - "recommend_distribution" — Determine which distribution type applies at a location.
80
+ - "climate_scenarios" — List available SSP scenarios and time horizons.
81
+ - "climate_factors" — Get climate change adjustment multipliers for a location.
82
+ - "climate_projection" — Apply climate change projections to Atlas 14 data.
83
+ - "uncertainty" — Get confidence interval bounds for a specific return period/duration.
84
+ - "uncertainty_envelope" — Monte Carlo uncertainty envelope for risk-based design.
85
+ - "sensitivity" — Sensitivity analysis on storm parameters.
86
+ - "design_approaches" — List design approaches for handling uncertainty.
87
+ - "status" — Check NOAA Atlas 14 service availability.`,
88
+ parameters: {
89
+ action: z.enum(['atlas14', 'idf', 'hyetograph', 'export_hyetograph', 'distributions', 'recommend_distribution', 'climate_scenarios', 'climate_factors', 'climate_projection', 'uncertainty', 'uncertainty_envelope', 'sensitivity', 'design_approaches', 'status'])
90
+ .describe('Which rainfall query to perform'),
91
+ lat: z.number().optional().describe('Latitude WGS84'),
92
+ lng: z.number().optional().describe('Longitude WGS84'),
93
+ units: z.enum(['english', 'metric']).optional().describe('Unit system (default: english)'),
94
+ series: z.enum(['pds', 'ams']).optional().describe('Statistical series (default: pds)'),
95
+ returnPeriod: z.string().optional().describe('Return period with "yr" suffix (e.g., "100yr")'),
96
+ returnPeriods: z.string().optional().describe('Comma-separated return periods (e.g., "2,5,10,25,50,100") for IDF'),
97
+ duration: z.number().optional().describe('Storm duration in hours'),
98
+ timeInterval: z.number().optional().describe('Time step in minutes'),
99
+ distribution: z.string().optional().describe('Rainfall distribution type (e.g., "SCS Type II")'),
100
+ horizon: z.string().optional().describe('Climate time horizon: "current", "mid-century", "late-century"'),
101
+ scenario: z.string().optional().describe('Climate scenario: "SSP2-4.5" or "SSP5-8.5"'),
102
+ format: z.enum(['csv', 'json']).optional().describe('Export format for export_hyetograph'),
103
+ nSamples: z.number().optional().describe('Monte Carlo samples for uncertainty_envelope (default: 500)'),
104
+ },
105
+ _actionMap: {
106
+ atlas14: 'get_rainfall_data',
107
+ idf: 'get_idf_curves',
108
+ hyetograph: 'generate_hyetograph',
109
+ export_hyetograph: 'export_hyetograph',
110
+ distributions: 'list_rainfall_distributions',
111
+ recommend_distribution: 'get_rainfall_distribution',
112
+ climate_scenarios: 'get_climate_scenarios',
113
+ climate_factors: 'get_climate_change_factors',
114
+ climate_projection: 'get_climate_change_rainfall_projection',
115
+ uncertainty: 'get_rainfall_uncertainty_bounds',
116
+ uncertainty_envelope: 'generate_uncertainty_envelope',
117
+ sensitivity: 'run_rainfall_sensitivity_analysis',
118
+ design_approaches: 'get_design_approaches',
119
+ status: 'check_rainfall_service_status',
120
+ }
121
+ },
122
+
123
+ // ═══════════════════════════════════════════════════════════════════
124
+ // 3. WATERSHED — Delineation, flow statistics, HUC boundaries
125
+ // ═══════════════════════════════════════════════════════════════════
126
+ {
127
+ name: 'get_watershed',
128
+ description: `Watershed delineation, flow statistics, stream networks, water quality, HUC boundaries, and FEMA flood map panels.
129
+
130
+ Actions:
131
+ - "delineate" — Trace watershed boundary for a pour point using USGS StreamStats. Returns drainage area polygon + basin characteristics.
132
+ - "characteristics" — Get physical/hydrologic basin characteristics (area, slope, precip, impervious %).
133
+ - "flow_statistics" — Estimated peak flows (2yr-500yr) and low flows from USGS regional regression.
134
+ - "flowlines" — Stream network (rivers, creeks) in a bounding box from NHD.
135
+ - "water_quality" — Water quality impairments (303d listed) for a watershed extent.
136
+ - "huc_boundaries" — HUC-8/10/12 watershed boundaries for a bounding box.
137
+ - "huc_by_code" — Get a specific HUC watershed boundary by its code.
138
+ - "firm_panels" — FEMA FIRM panel numbers for an area.`,
139
+ parameters: {
140
+ action: z.enum(['delineate', 'characteristics', 'flow_statistics', 'flowlines', 'water_quality', 'huc_boundaries', 'huc_by_code', 'firm_panels'])
141
+ .describe('Which watershed query to perform'),
142
+ lat: z.number().optional().describe('Latitude WGS84 (for delineate, characteristics, flow_statistics)'),
143
+ lng: z.number().optional().describe('Longitude WGS84'),
144
+ bbox: z.string().optional().describe('Bounding box "west,south,east,north" (for flowlines, water_quality, huc_boundaries, firm_panels)'),
145
+ region: z.string().optional().describe('StreamStats region code (auto-detected if omitted)'),
146
+ drainageArea: z.number().optional().describe('Known drainage area in sq mi (improves flow_statistics accuracy)'),
147
+ huc12: z.string().optional().describe('HUC-12 code (for water_quality if known)'),
148
+ hucCode: z.string().optional().describe('HUC code for huc_by_code'),
149
+ hucLevel: z.enum(['8', '10', '12']).optional().describe('HUC level for huc_boundaries (default: 12)'),
150
+ },
151
+ _actionMap: {
152
+ delineate: 'delineate_watershed',
153
+ characteristics: 'get_watershed_characteristics',
154
+ flow_statistics: 'get_flow_statistics',
155
+ flowlines: 'get_flowlines',
156
+ water_quality: 'get_watershed_water_quality',
157
+ huc_boundaries: 'get_huc_watersheds',
158
+ huc_by_code: 'get_huc_watershed_by_code',
159
+ firm_panels: 'get_firm_panels',
160
+ }
161
+ },
162
+
163
+ // ═══════════════════════════════════════════════════════════════════
164
+ // 4. HYDROLOGY — Curve numbers, runoff, engineering calculations
165
+ // ═══════════════════════════════════════════════════════════════════
166
+ {
167
+ name: 'get_hydrology',
168
+ description: `Hydrologic engineering calculations: curve numbers, runoff, time of concentration, peak discharge.
169
+
170
+ Actions:
171
+ - "analyze" — Comprehensive hydrologic analysis on catchments: composite CN, Tc, SCS runoff depth, peak discharge (Rational, TR-55, regression). The all-in-one hydrology tool.
172
+ - "distributions" — List rainfall distribution types for hydrologic analysis.
173
+ - "distribution_for_location" — Recommended SCS distribution for a specific location.
174
+ - "lookup_cn" — Look up SCS curve number for a specific land use (NLCD code) + soil type (HSG).
175
+ - "cn_tables" — Full SCS curve number reference tables.
176
+ - "analyze_cn" — Calculate weighted curve numbers for catchments using NLCD + SSURGO data.`,
177
+ parameters: {
178
+ action: z.enum(['analyze', 'distributions', 'distribution_for_location', 'lookup_cn', 'cn_tables', 'analyze_cn'])
179
+ .describe('Which hydrology calculation to perform'),
180
+ lat: z.number().optional().describe('Latitude WGS84 (for distribution_for_location)'),
181
+ lng: z.number().optional().describe('Longitude WGS84'),
182
+ catchments: z.any().optional().describe('GeoJSON FeatureCollection of catchment polygons (for analyze, analyze_cn)'),
183
+ options: z.any().optional().describe('Analysis options: {hydrologicCondition, dualHSGTreatment, returnPeriods, durations}'),
184
+ nlcd: z.number().optional().describe('NLCD land cover code for lookup_cn (e.g., 21=Developed Open Space)'),
185
+ hsg: z.string().optional().describe('Hydrologic Soil Group A/B/C/D for lookup_cn'),
186
+ condition: z.string().optional().describe('Antecedent moisture: "good", "fair", "poor" for lookup_cn'),
187
+ },
188
+ _actionMap: {
189
+ analyze: 'analyze_hydrology',
190
+ distributions: 'get_hydrology_distributions',
191
+ distribution_for_location: 'get_hydrology_distribution_for_location',
192
+ lookup_cn: 'lookup_curve_number',
193
+ cn_tables: 'get_curve_number_tables',
194
+ analyze_cn: 'analyze_curve_numbers',
195
+ }
196
+ },
197
+
198
+ // ═══════════════════════════════════════════════════════════════════
199
+ // 5. WATER QUALITY — EPA ATTAINS, impairments, receiving waters
200
+ // ═══════════════════════════════════════════════════════════════════
201
+ {
202
+ name: 'get_water_quality',
203
+ description: `Water quality impairment data from EPA ATTAINS — 303(d) listed impaired waters, pollutants, designated uses.
204
+
205
+ Actions:
206
+ - "assessment" — Full water quality assessment for a location: impaired waters, pollutants, downstream receiving water trace.
207
+ - "impairments" — Quick impairment check by HUC-12 code. Faster when you know the HUC.
208
+ - "find_watershed" — Identify which HUC-12 watershed a point falls within.`,
209
+ parameters: {
210
+ action: z.enum(['assessment', 'impairments', 'find_watershed'])
211
+ .describe('Which water quality query to perform'),
212
+ lat: z.number().optional().describe('Latitude WGS84 (for find_watershed)'),
213
+ lng: z.number().optional().describe('Longitude WGS84'),
214
+ location: z.any().optional().describe('GeoJSON Point or Polygon (for assessment)'),
215
+ huc12: z.string().optional().describe('12-digit HUC code (for impairments)'),
216
+ options: z.object({
217
+ includeDownstream: z.boolean().optional(),
218
+ radiusKm: z.number().optional(),
219
+ }).optional().describe('Assessment options'),
220
+ },
221
+ _actionMap: {
222
+ assessment: 'get_water_quality',
223
+ impairments: 'get_water_impairments',
224
+ find_watershed: 'get_watershed_for_point',
225
+ }
226
+ },
227
+
228
+ // ═══════════════════════════════════════════════════════════════════
229
+ // 6. ELEVATION — USGS 3DEP, contours, DEM, land use, imagery
230
+ // ═══════════════════════════════════════════════════════════════════
231
+ {
232
+ name: 'get_elevation',
233
+ description: `Elevation, terrain, land use, and satellite imagery from USGS 3DEP, NLCD, and NAIP.
234
+
235
+ Actions:
236
+ - "stats" — Elevation statistics (min, max, mean, range) for a bounding box.
237
+ - "contours" — Generate contour lines at specified intervals for a bbox.
238
+ - "contour_options" — Available contour interval options.
239
+ - "export_dem" — Export DEM as GeoTIFF (1m/10m/30m resolution).
240
+ - "export_contours" — Export contour lines as GeoJSON for a polygon.
241
+ - "availability" — Check which DEM resolutions are available for an area.
242
+ - "resolution_options" — List supported DEM resolution options.
243
+ - "export_land_use" — Export NLCD land cover data as GeoTIFF or polygons.
244
+ - "export_satellite" — Export aerial photography as GeoTIFF.
245
+ - "satellite_options" — Available satellite imagery resolutions.`,
246
+ parameters: {
247
+ action: z.enum(['stats', 'contours', 'contour_options', 'export_dem', 'export_contours', 'availability', 'resolution_options', 'export_land_use', 'export_satellite', 'satellite_options'])
248
+ .describe('Which elevation/terrain query to perform'),
249
+ bbox: z.string().optional().describe('Bounding box "west,south,east,north" (for stats, contours, availability)'),
250
+ polygon: z.any().optional().describe('GeoJSON Polygon (for export_dem, export_contours, export_land_use, export_satellite)'),
251
+ interval: z.number().optional().describe('Contour interval in feet'),
252
+ intervalMeters: z.number().optional().describe('Contour interval in meters'),
253
+ resolution: z.string().optional().describe('DEM resolution "1m"/"10m"/"30m" or satellite "high"/"medium"/"low"'),
254
+ targetCrs: z.string().optional().describe('Target CRS (e.g., "EPSG:2277")'),
255
+ convertToFeet: z.boolean().optional().describe('Convert elevations to feet'),
256
+ clipToPolygon: z.boolean().optional().describe('Clip raster to polygon boundary'),
257
+ format: z.enum(['geotiff', 'polygons']).optional().describe('Land use export format'),
258
+ demResolution: z.enum(['1m', '10m', '30m']).optional().describe('DEM resolution for contour export'),
259
+ },
260
+ _actionMap: {
261
+ stats: 'get_elevation_stats',
262
+ contours: 'get_contour_lines',
263
+ contour_options: 'get_contour_interval_options',
264
+ export_dem: 'export_dem',
265
+ export_contours: 'export_contours',
266
+ availability: 'check_dem_availability',
267
+ resolution_options: 'get_dem_resolution_options',
268
+ export_land_use: 'export_land_use',
269
+ export_satellite: 'export_satellite_imagery',
270
+ satellite_options: 'get_satellite_resolution_options',
271
+ }
272
+ },
273
+
274
+ // ═══════════════════════════════════════════════════════════════════
275
+ // 7. GAGE ANALYSIS — Stream gage data, flood frequency, storm events
276
+ // ═══════════════════════════════════════════════════════════════════
277
+ {
278
+ name: 'analyze_gage',
279
+ description: `Analyze USGS stream gage data: flood frequency, flow duration, low flow, storm events, and published statistics.
280
+
281
+ Actions:
282
+ - "summary" — Quick overview of a gage: period of record, drainage area, key flows.
283
+ - "flood_frequency" — Bulletin 17C flood frequency analysis (2yr-500yr peak flows).
284
+ - "flow_duration" — Flow duration curve and percentiles (Q1 through Q99).
285
+ - "low_flow" — Low flow statistics: 7Q10, 7Q2, harmonic mean. Critical for NPDES permits.
286
+ - "storm_events" — Detect storm events from flow record with peak, volume, duration.
287
+ - "storm_detail" — Detailed hydrograph for a specific storm event.
288
+ - "export_storm" — Export storm hydrograph for HEC-HMS or other models.
289
+ - "published_stats" — Official USGS GageStats published values (peer-reviewed).
290
+ - "compare_stats" — Compare computed vs. published statistics for QA.`,
291
+ parameters: {
292
+ action: z.enum(['summary', 'flood_frequency', 'flow_duration', 'low_flow', 'storm_events', 'storm_detail', 'export_storm', 'published_stats', 'compare_stats'])
293
+ .describe('Which gage analysis to perform'),
294
+ siteId: z.string().describe('USGS station ID (e.g., "08158000")'),
295
+ eventId: z.string().optional().describe('Storm event ID (for storm_detail, export_storm)'),
296
+ minYears: z.number().optional().describe('Minimum years of record required'),
297
+ startDate: z.string().optional().describe('Start date YYYY-MM-DD (for flow_duration)'),
298
+ endDate: z.string().optional().describe('End date YYYY-MM-DD (for flow_duration)'),
299
+ period: z.string().optional().describe('Time period "1y"/"5y"/"10y" (for storm_events)'),
300
+ minPeak: z.number().optional().describe('Minimum peak flow in cfs (for storm_events)'),
301
+ format: z.string().optional().describe('Export format (default: "hec-hms")'),
302
+ },
303
+ _actionMap: {
304
+ summary: 'get_gage_summary',
305
+ flood_frequency: 'get_flood_frequency_analysis',
306
+ flow_duration: 'get_flow_duration_curve',
307
+ low_flow: 'get_low_flow_statistics',
308
+ storm_events: 'get_storm_events',
309
+ storm_detail: 'get_storm_event_detail',
310
+ export_storm: 'export_storm_event_for_modeling',
311
+ published_stats: 'get_published_gage_statistics',
312
+ compare_stats: 'compare_computed_vs_published_stats',
313
+ }
314
+ },
315
+
316
+ // ═══════════════════════════════════════════════════════════════════
317
+ // 8. UNGAGED ESTIMATION — Flow estimates at sites without gages
318
+ // ═══════════════════════════════════════════════════════════════════
319
+ {
320
+ name: 'estimate_ungaged',
321
+ description: `Estimate flows at ungaged sites using USGS regional regression, watershed similarity, and drainage area transfer methods.
322
+
323
+ Actions:
324
+ - "flood_frequency" — Estimate flood flows using NSS regional regression equations. Requires state, region, and basin characteristics.
325
+ - "all_statistics" — Estimate ALL available flow statistics (peak, low, duration) for an ungaged site.
326
+ - "nss_regions" — List available NSS regions for a state.
327
+ - "required_parameters" — What basin characteristics are needed for a state/region.
328
+ - "find_similar" — Find gauged watersheds with similar physical characteristics.
329
+ - "find_similar_with_stats" — Similar watersheds + their published flow statistics.
330
+ - "recommend_index" — Find the best reference gage for flow transfer.
331
+ - "transfer_stats" — Transfer flood statistics from a reference gage using drainage area ratio.`,
332
+ parameters: {
333
+ action: z.enum(['flood_frequency', 'all_statistics', 'nss_regions', 'required_parameters', 'find_similar', 'find_similar_with_stats', 'recommend_index', 'transfer_stats'])
334
+ .describe('Which estimation method to use'),
335
+ lat: z.number().optional().describe('Latitude of ungaged site'),
336
+ lng: z.number().optional().describe('Longitude of ungaged site'),
337
+ state: z.string().optional().describe('US state code (e.g., "TX")'),
338
+ region: z.string().optional().describe('NSS region code'),
339
+ parameters: z.any().optional().describe('Basin characteristics object (e.g., {drainageArea: 10.5, meanBasinSlope: 3.2})'),
340
+ characteristics: z.any().optional().describe('Known basin characteristics for similarity matching'),
341
+ maxDistance: z.number().optional().describe('Max search distance in km for similarity'),
342
+ limit: z.number().optional().describe('Max results'),
343
+ indexSiteId: z.string().optional().describe('Reference gage ID for transfer_stats'),
344
+ targetDrainageArea: z.number().optional().describe('Ungaged site drainage area in sq mi for transfer_stats'),
345
+ drainageArea: z.number().optional().describe('Drainage area for recommend_index'),
346
+ },
347
+ _actionMap: {
348
+ flood_frequency: 'estimate_ungaged_flood_frequency',
349
+ all_statistics: 'estimate_all_ungaged_statistics',
350
+ nss_regions: 'get_ungaged_nss_regions',
351
+ required_parameters: 'get_ungaged_required_parameters',
352
+ find_similar: 'find_similar_watersheds',
353
+ find_similar_with_stats: 'find_similar_watersheds_with_stats',
354
+ recommend_index: 'recommend_index_gage',
355
+ transfer_stats: 'transfer_flood_statistics',
356
+ }
357
+ },
358
+
359
+ // ═══════════════════════════════════════════════════════════════════
360
+ // 9. GENERATE REPORT — Site analysis, constraints, developability
361
+ // ═══════════════════════════════════════════════════════════════════
362
+ {
363
+ name: 'generate_report',
364
+ description: `Generate comprehensive environmental reports for development sites.
365
+
366
+ Actions:
367
+ - "site_analysis" — Full environmental site analysis: flood risk, wetlands, soils, hazards, habitat, contamination. Returns developability score 0-100.
368
+ - "site_analysis_status" — Check status of a site analysis job (can take 30-60s).
369
+ - "constraints" — Environmental constraints report: floodway, flood zones, wetlands, hydric soils, steep slopes. Calculates constrained vs. developable area.
370
+ - "constraints_status" — Check status of constraints report job.
371
+ - "constraints_config" — Available constraint report options.
372
+ - "developability" — Site developability assessment with 0-100 score and penalty breakdown.
373
+ - "developability_config" — Available developability assessment options.`,
374
+ parameters: {
375
+ action: z.enum(['site_analysis', 'site_analysis_status', 'constraints', 'constraints_status', 'constraints_config', 'developability', 'developability_config'])
376
+ .describe('Which report to generate or check'),
377
+ geometry: z.any().optional().describe('GeoJSON Point or Polygon for the site'),
378
+ projectName: z.string().optional().describe('Project name for report'),
379
+ clientName: z.string().optional().describe('Client name for report'),
380
+ jobId: z.string().optional().describe('Job ID for status checks'),
381
+ options: z.any().optional().describe('Report options'),
382
+ },
383
+ _actionMap: {
384
+ site_analysis: 'generate_site_analysis',
385
+ site_analysis_status: 'get_site_analysis_status',
386
+ constraints: 'generate_constraints_report',
387
+ constraints_status: 'get_constraints_report_status',
388
+ constraints_config: 'get_constraints_config',
389
+ developability: 'generate_developability_report',
390
+ developability_config: 'get_developability_config',
391
+ }
392
+ },
393
+
394
+ // ═══════════════════════════════════════════════════════════════════
395
+ // 10. EXPORT DATA — Multi-format GIS export
396
+ // ═══════════════════════════════════════════════════════════════════
397
+ {
398
+ name: 'export_data',
399
+ description: `Export environmental data layers to GIS formats (GeoJSON, Shapefile, KML, CSV, GeoPackage).
400
+
401
+ Actions:
402
+ - "export" — Export layers to a file format. Supports CRS transformation and clipping.
403
+ - "options" — List available export formats and CRS options.
404
+ - "status" — Check export job status.`,
405
+ parameters: {
406
+ action: z.enum(['export', 'options', 'status'])
407
+ .describe('Which export operation'),
408
+ layers: z.array(z.string()).optional().describe('Layer names to export'),
409
+ format: z.enum(['geojson', 'shapefile', 'kml', 'csv', 'geopackage']).optional().describe('Output format'),
410
+ crs: z.string().optional().describe('Target CRS (e.g., "EPSG:4326")'),
411
+ geometry: z.any().optional().describe('GeoJSON geometry to clip export area'),
412
+ options: z.any().optional().describe('Additional options: {dem, satellite, nlcd, contours}'),
413
+ jobId: z.string().optional().describe('Job ID for status check'),
414
+ },
415
+ _actionMap: {
416
+ export: 'export_data',
417
+ options: 'get_export_options',
418
+ status: 'get_export_status',
419
+ }
420
+ },
421
+
422
+ // ═══════════════════════════════════════════════════════════════════
423
+ // 11. FIND STATIONS — Monitoring stations & permits
424
+ // ═══════════════════════════════════════════════════════════════════
425
+ {
426
+ name: 'find_stations',
427
+ description: `Search for environmental monitoring stations (USGS streamgages, groundwater wells, weather stations, tide gauges) and analyze waterway permit requirements.
428
+
429
+ Actions:
430
+ - "search_area" — Find stations near a location or in a bounding box.
431
+ - "search_name" — Search stations by name or ID.
432
+ - "station_types" — List all available station types.
433
+ - "find_water_features" — Find streams, wetlands, waterbodies for permit analysis.
434
+ - "analyze_permits" — Determine required permits (Section 404, NPDES, etc.) for an activity near water.`,
435
+ parameters: {
436
+ action: z.enum(['search_area', 'search_name', 'station_types', 'find_water_features', 'analyze_permits'])
437
+ .describe('Which search to perform'),
438
+ // Station search params
439
+ bbox: z.string().optional().describe('Bounding box for area search'),
440
+ source: z.string().optional().describe('Data source filter: "usgs", "noaa"'),
441
+ type: z.string().optional().describe('Station type: "stream_gage", "groundwater", "tide", "precipitation"'),
442
+ state: z.string().optional().describe('US state code'),
443
+ q: z.string().optional().describe('Search query for search_name'),
444
+ limit: z.number().optional().describe('Max results'),
445
+ // Permit params
446
+ polygon: z.any().optional().describe('GeoJSON Polygon for find_water_features'),
447
+ selectedFeatures: z.any().optional().describe('Water features from find_water_features for analyze_permits'),
448
+ activityType: z.enum(['crossing', 'utility_crossing', 'stormwater_discharge', 'wetland_fill', 'bank_stabilization', 'adjacent_construction']).optional()
449
+ .describe('Activity type for analyze_permits'),
450
+ location: z.any().optional().describe('GeoJSON Point for activity location'),
451
+ },
452
+ _actionMap: {
453
+ search_area: 'find_monitoring_stations',
454
+ search_name: 'search_stations',
455
+ station_types: 'get_station_types',
456
+ find_water_features: 'find_water_features',
457
+ analyze_permits: 'analyze_permit_requirements',
458
+ }
459
+ },
460
+
461
+ // ═══════════════════════════════════════════════════════════════════
462
+ // 12. CHECK STATUS — API health
463
+ // ═══════════════════════════════════════════════════════════════════
464
+ {
465
+ name: 'check_status',
466
+ description: `Check GeoTap API health and federal data source connectivity.
467
+
468
+ Actions:
469
+ - "all" — Check all connected federal APIs (FEMA, USGS, EPA, NOAA, etc.).
470
+ - "specific" — Check one specific API.`,
471
+ parameters: {
472
+ action: z.enum(['all', 'specific']).describe('Check all APIs or a specific one'),
473
+ apiName: z.string().optional().describe('API name for specific check: "fema", "usgs", "epa", "noaa", "nrcs"'),
474
+ },
475
+ _actionMap: {
476
+ all: 'check_api_status',
477
+ specific: 'check_specific_api_status',
478
+ }
479
+ },
480
+ ];
package/src/index.js CHANGED
@@ -4,72 +4,246 @@ 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 { consolidatedTools } from './consolidatedTools.js';
7
8
  import { callApi, StructuredApiError } from './api.js';
8
9
  import { toolSources } from './sources.js';
9
10
  import { capResponse } from './responseCap.js';
10
11
  import { generateSummary } from './summaries.js';
11
12
  import { convertLatLng } from './latLngHelper.js';
13
+ import { normalizeParams } from './paramNormalize.js';
12
14
  import { discoverTools } from './discoverTools.js';
13
15
  import { readFileSync } from 'fs';
14
16
  import { fileURLToPath } from 'url';
15
17
  import { dirname, join } from 'path';
16
18
 
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const useLegacyTools = process.env.GEOTAP_LEGACY_TOOLS === 'true';
21
+
22
+ // Build lookup map for legacy tools (used by both modes)
23
+ const legacyToolMap = new Map(tools.map(t => [t.name, t]));
18
24
 
19
25
  const server = new McpServer({
20
26
  name: 'geotap',
21
- version: '1.3.0',
27
+ version: '2.0.0',
22
28
  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.',
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).
24
-
25
- START HERE CORE TOOLS (use these for 90% of queries):
26
- 1. query_address — Geocode + environmental lookup in ONE call. Always start here when user gives an address.
27
- 2. identify_features_at_point Same as above but when you already have lat/lng coordinates.
28
- 3. get_rainfall_dataNOAA Atlas 14 precipitation data for any US location.
29
- 4. get_environmental_summaryQuick feature counts for an area (no geometry, just numbers).
30
- 5. discover_toolsDon't know which tool to use? Describe your question and get the best matches.
31
-
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.
38
-
39
- RESPONSE SIZE MANAGEMENT:
40
- - query_address and identify_features_at_point always return <5KB (no geometry, just properties + interpretations).
41
- - For all other spatial tools, ALWAYS set geometry="none" unless the user specifically needs coordinates.
42
- - Always specify layers (e.g., layers="flood_zones,wetlands") instead of querying all 19 layers.
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.
29
+ instructions: useLegacyTools
30
+ ? `You have access to GeoTap with 85 individual tools. Use discover_tools to find the right one.`
31
+ : `You have access to GeoTap, which provides real-time data from 37 US federal agencies (FEMA, USGS, NOAA, EPA, NRCS, USFWS, USACE, and more).
32
+
33
+ TOOL OVERVIEW (12 tools + 2 meta-tools):
34
+ 1. query_locationStart here. Query environmental data by address, coordinates, bbox, polygon, or radius.
35
+ 2. get_rainfallNOAA Atlas 14 precipitation, IDF curves, hyetographs, climate projections.
36
+ 3. get_watershedWatershed delineation, flow statistics, flowlines, HUC boundaries, FIRM panels.
37
+ 4. get_hydrology — Curve numbers, runoff calculations, time of concentration, peak discharge.
38
+ 5. get_water_quality EPA water quality impairments, 303(d) listings, receiving waters.
39
+ 6. get_elevation USGS 3DEP elevation, contours, DEM export, land use, satellite imagery.
40
+ 7. analyze_gage USGS stream gage analysis: flood frequency, flow duration, storm events.
41
+ 8. estimate_ungaged Flow estimation at ungaged sites: regression, similarity, transfer methods.
42
+ 9. generate_report Site analysis, constraints, and developability reports with scoring.
43
+ 10. export_data Export layers to GeoJSON, Shapefile, KML, CSV, GeoPackage.
44
+ 11. find_stations — Find monitoring stations (USGS, NOAA) and analyze waterway permit requirements.
45
+ 12. check_status — API health and federal data source connectivity.
46
+
47
+ Every tool uses an "action" parameter to select the specific operation. Read the tool description to see available actions.
48
+
49
+ COORDINATE FLEXIBILITY:
50
+ - All tools accept lat/lng, lat/lon, or latitude/longitude — they are automatically normalized.
51
+ - POST tools accept flat lat/lng instead of GeoJSON — auto-converted to the correct format.
52
+
53
+ RESPONSE SIZE:
54
+ - geometry defaults to "none" for spatial queries (prevents context overflow).
55
+ - Responses auto-capped at 40KB (~10K tokens). Use specific layers to get smaller responses.
56
+ - Always specify layers (e.g., layers="flood_zones,wetlands") instead of querying all layers.
49
57
 
50
58
  COMMON WORKFLOWS:
51
- - "What flood zone is this address in?" → query_address (one call, done)
52
- - "Is this a good place to build?" → query_address get_rainfall_data → get_environmental_summary
53
- - "Environmental due diligence" → query_address (covers flood, wetlands, soils, contamination, habitat)
54
- - "What's the 100-year rainfall?" → get_rainfall_data
55
- - "Hydrology analysis" watershed delineate + curve numbers + rainfall + peak flow
56
- - "Export data" → query layers, then export tool for GeoJSON/Shapefile/CSV/KML
57
-
58
- IMPORTANT NOTES:
59
- - All data comes from authoritative federal sources. Always mention the source agency.
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.
62
- - This data is for informational purposes. Remind users to verify critical data for engineering/regulatory decisions.
63
- - Coordinates must be within the United States (including territories).
64
- - Some tools (watershed delineation, hydrology) can take 10-60 seconds.
65
- - Layer names use underscores: flood_zones, wetlands, dem_elevation, building_footprints, etc.`
59
+ - "What flood zone is this address in?" → query_location(action: "address", address: "...")
60
+ - "What's the 100-year rainfall?" → get_rainfall(action: "atlas14", lat, lng)
61
+ - "Delineate the watershed" → get_watershed(action: "delineate", lat, lng)
62
+ - "Environmental site analysis" → generate_report(action: "site_analysis", geometry: ...)
63
+ - "What permits do I need?" find_stations(action: "find_water_features") find_stations(action: "analyze_permits")
64
+
65
+ IMPORTANT:
66
+ - All data from authoritative US federal sources. Always cite the source agency.
67
+ - Responses include _summary with plain-English descriptions use these in answers.
68
+ - Data is for informational purposes. Remind users to verify for engineering/regulatory decisions.
69
+ - Coordinates must be within the United States (including territories).`
66
70
  });
67
71
 
68
- // ── Register discover_tools meta-tool ───────────────────────────────
72
+ // ── Shared tool call handler ────────────────────────────────────────
73
+
74
+ /**
75
+ * Handle a tool call through the standard pipeline:
76
+ * normalize → convertLatLng → callApi → capResponse → generateSummary → checkDataQuality → enrich
77
+ *
78
+ * @param {string} legacyToolName - The original tool name (for routing summaries/sources)
79
+ * @param {string} endpoint - API endpoint path
80
+ * @param {string} method - HTTP method (GET/POST)
81
+ * @param {object} params - Parameters from the LLM (after action extraction)
82
+ * @returns {object} MCP tool response
83
+ */
84
+ async function handleToolCall(legacyToolName, endpoint, method, params) {
85
+ try {
86
+ // Fix #1: Normalize coordinate params to what backend expects
87
+ const normalized = normalizeParams(legacyToolName, params);
88
+
89
+ // Convert lat/lng to GeoJSON if needed (existing feature)
90
+ const convertedParams = convertLatLng(legacyToolName, normalized);
91
+
92
+ // Strip internal fields before sending to API
93
+ const apiParams = { ...convertedParams };
94
+ delete apiParams._latLngConverted;
95
+
96
+ const rawResult = await callApi(endpoint, method, apiParams);
97
+
98
+ // Cap response size
99
+ const { data: cappedResult, wasCapped, capInfo } = capResponse(legacyToolName, rawResult);
100
+
101
+ // Generate natural language summary
102
+ const summary = generateSummary(legacyToolName, params, cappedResult);
103
+
104
+ // Fix #5: Check data quality and add warnings
105
+ const dataQuality = checkDataQuality(legacyToolName, cappedResult);
106
+
107
+ // Enrich response with source attribution, summary, and metadata
108
+ const sources = toolSources[legacyToolName] || [];
109
+ const enriched = {
110
+ ...(summary ? { _summary: summary } : {}),
111
+ ...cappedResult,
112
+ ...(convertedParams._latLngConverted ? { _latLngConverted: convertedParams._latLngConverted } : {}),
113
+ ...(wasCapped ? { _responseCapped: capInfo } : {}),
114
+ ...(dataQuality ? { _dataQuality: dataQuality } : {}),
115
+ _meta: {
116
+ sources,
117
+ retrievedAt: new Date().toISOString(),
118
+ disclaimer: 'Data sourced from US federal agencies via GeoTap. Always verify critical data against authoritative sources before making engineering or regulatory decisions.',
119
+ }
120
+ };
121
+
122
+ return {
123
+ content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }]
124
+ };
125
+ } catch (error) {
126
+ if (error instanceof StructuredApiError) {
127
+ return {
128
+ content: [{ type: 'text', text: JSON.stringify(error.details, null, 2) }],
129
+ isError: true
130
+ };
131
+ }
132
+
133
+ return {
134
+ content: [{ type: 'text', text: JSON.stringify({
135
+ error: true,
136
+ message: error.message,
137
+ fix: ['Check that all required parameters are provided and valid.', 'Use discover_tools or check the tool description for available actions.'],
138
+ relatedTools: ['discover_tools', 'check_status']
139
+ }, null, 2) }],
140
+ isError: true
141
+ };
142
+ }
143
+ }
144
+
145
+ // ── Fix #5: Data quality warnings ───────────────────────────────────
146
+
147
+ /**
148
+ * Check API response for signs of degraded or incomplete data.
149
+ * Returns a _dataQuality object with warnings, or null if no issues detected.
150
+ */
151
+ function checkDataQuality(toolName, result) {
152
+ if (!result || typeof result !== 'object') return null;
153
+
154
+ const warnings = [];
155
+
156
+ // Check for empty layer results in spatial queries
157
+ if (result.layers && typeof result.layers === 'object') {
158
+ const emptyLayers = [];
159
+ const populatedLayers = [];
160
+ for (const [name, data] of Object.entries(result.layers)) {
161
+ if (data?.features?.length === 0 || data?.featureCount === 0) {
162
+ emptyLayers.push(name);
163
+ } else if (data?.features?.length > 0) {
164
+ populatedLayers.push(name);
165
+ }
166
+ }
167
+ if (emptyLayers.length > 0 && populatedLayers.length > 0) {
168
+ // Only warn if some layers returned data but others didn't
169
+ // (if ALL are empty, that's a valid "nothing here" result)
170
+ warnings.push({
171
+ type: 'partial_results',
172
+ message: `${emptyLayers.length} layer(s) returned no features: ${emptyLayers.join(', ')}. This may be expected (no features at this location) or may indicate a data gap.`,
173
+ layers: emptyLayers,
174
+ });
175
+ }
176
+ }
177
+
178
+ // Check for empty watershed geometry (the 82% failure pattern)
179
+ if (toolName === 'delineate_watershed' || toolName === 'get_watershed') {
180
+ const geom = result.data?.geometry || result.geometry || result.boundary?.geometry;
181
+ if (geom && geom.coordinates && geom.coordinates.length === 0) {
182
+ warnings.push({
183
+ type: 'empty_geometry',
184
+ message: 'Watershed delineation returned empty geometry. USGS StreamStats may not have coverage for this location. Try a nearby point on a mapped stream.',
185
+ severity: 'error',
186
+ });
187
+ }
188
+ if (result.data?.characteristics?.drainageArea === null || result.data?.characteristics?.drainageArea === undefined) {
189
+ if (result.data?.characteristics) {
190
+ warnings.push({
191
+ type: 'missing_field',
192
+ message: 'Drainage area not returned by StreamStats. This basin characteristic may not be available for this region.',
193
+ field: 'drainageArea',
194
+ });
195
+ }
196
+ }
197
+ }
198
+
199
+ // Check for null key fields in gage data
200
+ if (toolName.startsWith('get_gage') || toolName.startsWith('get_flood') || toolName.startsWith('get_flow') || toolName.startsWith('get_storm')) {
201
+ if (result.drainageArea === null || result.drainageArea === undefined) {
202
+ if (result.siteName || result.siteId) {
203
+ warnings.push({
204
+ type: 'missing_field',
205
+ message: 'Drainage area is null for this gage. USGS NWIS may not have this metadata. Cross-reference with StreamStats if drainage area is needed.',
206
+ field: 'drainageArea',
207
+ });
208
+ }
209
+ }
210
+ }
211
+
212
+ // Check for stale NLCD data
213
+ if (toolName === 'export_land_use' || toolName === 'analyze_curve_numbers') {
214
+ warnings.push({
215
+ type: 'data_currency',
216
+ message: 'NLCD land cover data is from 2021 (latest available). For rapidly developing areas, ground-truth current conditions before relying on land use classifications.',
217
+ severity: 'info',
218
+ });
219
+ }
220
+
221
+ // Check for Atlas 14 metadata issues (Ohio River Basin bug)
222
+ if (toolName === 'get_rainfall_data' || toolName === 'get_idf_curves') {
223
+ const loc = result.location || result.metadata?.location;
224
+ if (loc?.region && /ohio\s*river\s*basin/i.test(loc.region)) {
225
+ // Check if the actual coordinates suggest a different region
226
+ const lat = result.metadata?.lat || result.lat;
227
+ if (lat && lat < 36) {
228
+ warnings.push({
229
+ type: 'metadata_mismatch',
230
+ message: 'NOAA Atlas 14 reports "Ohio River Basin" as the region, but the coordinates may be in a different region. The rainfall data values are still correct — the region label is a known NOAA metadata issue for some locations.',
231
+ severity: 'info',
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ return warnings.length > 0 ? { warnings, totalWarnings: warnings.length } : null;
238
+ }
239
+
240
+ // ── Register meta-tools ─────────────────────────────────────────────
69
241
 
70
242
  server.tool(
71
243
  '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.',
244
+ useLegacyTools
245
+ ? '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.'
246
+ : 'Find the best GeoTap tool and action for your question. Describe what you need in plain English. Helpful when you\'re unsure which tool or action to use.',
73
247
  {
74
248
  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
249
  maxResults: z.number().optional().describe('Maximum tools to return (default: 5)')
@@ -82,11 +256,9 @@ server.tool(
82
256
  }
83
257
  );
84
258
 
85
- // ── Register get_llms_txt tool ──────────────────────────────────────
86
-
87
259
  server.tool(
88
260
  '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.',
261
+ '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.',
90
262
  {},
91
263
  async () => {
92
264
  try {
@@ -101,68 +273,59 @@ server.tool(
101
273
  }
102
274
  );
103
275
 
104
- // ── Register all API tools ──────────────────────────────────────────
105
-
106
- for (const tool of tools) {
107
- server.tool(
108
- tool.name,
109
- tool.description,
110
- tool.parameters,
111
- async (params) => {
112
- try {
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);
121
-
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
129
- const sources = toolSources[tool.name] || [];
130
- const enriched = {
131
- ...(summary ? { _summary: summary } : {}),
132
- ...cappedResult,
133
- ...(convertedParams._latLngConverted ? { _latLngConverted: convertedParams._latLngConverted } : {}),
134
- ...(wasCapped ? { _responseCapped: capInfo } : {}),
135
- _meta: {
136
- sources,
137
- retrievedAt: new Date().toISOString(),
138
- disclaimer: 'Data sourced from US federal agencies via GeoTap. Always verify critical data against authoritative sources before making engineering or regulatory decisions.',
139
- }
140
- };
141
-
142
- return {
143
- content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }]
144
- };
145
- } catch (error) {
146
- // Improvement #5: Structured error messages
147
- if (error instanceof StructuredApiError) {
276
+ // ── Register tools based on mode ────────────────────────────────────
277
+
278
+ if (useLegacyTools) {
279
+ // Legacy mode: register all 85 individual tools (for existing consumers)
280
+ for (const tool of tools) {
281
+ server.tool(
282
+ tool.name,
283
+ tool.description,
284
+ tool.parameters,
285
+ async (params) => handleToolCall(tool.name, tool.endpoint, tool.method, params)
286
+ );
287
+ }
288
+ console.error(`[geotap] Legacy mode: registered ${tools.length} individual tools`);
289
+ } else {
290
+ // Default: register 12 consolidated tools
291
+ for (const ctool of consolidatedTools) {
292
+ server.tool(
293
+ ctool.name,
294
+ ctool.description,
295
+ ctool.parameters,
296
+ async (params) => {
297
+ const { action, ...restParams } = params;
298
+
299
+ // Resolve consolidated action → legacy tool
300
+ const legacyName = ctool._actionMap[action];
301
+ if (!legacyName) {
302
+ return {
303
+ content: [{ type: 'text', text: JSON.stringify({
304
+ error: true,
305
+ message: `Unknown action "${action}" for tool "${ctool.name}".`,
306
+ validActions: Object.keys(ctool._actionMap),
307
+ fix: [`Use one of: ${Object.keys(ctool._actionMap).join(', ')}`],
308
+ }, null, 2) }],
309
+ isError: true
310
+ };
311
+ }
312
+
313
+ const legacyTool = legacyToolMap.get(legacyName);
314
+ if (!legacyTool) {
148
315
  return {
149
- content: [{ type: 'text', text: JSON.stringify(error.details, null, 2) }],
316
+ content: [{ type: 'text', text: JSON.stringify({
317
+ error: true,
318
+ message: `Internal routing error: legacy tool "${legacyName}" not found.`,
319
+ }, null, 2) }],
150
320
  isError: true
151
321
  };
152
322
  }
153
323
 
154
- return {
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) }],
161
- isError: true
162
- };
324
+ return handleToolCall(legacyName, legacyTool.endpoint, legacyTool.method, restParams);
163
325
  }
164
- }
165
- );
326
+ );
327
+ }
328
+ console.error(`[geotap] Consolidated mode: registered ${consolidatedTools.length} tools (set GEOTAP_LEGACY_TOOLS=true for 85 individual tools)`);
166
329
  }
167
330
 
168
331
  // Start server
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Parameter Normalization for MCP Tools
3
+ *
4
+ * Fixes the #1 LLM error pattern: inconsistent coordinate parameter naming.
5
+ * The backend has 3 conventions: lat/lng, lat/lon, latitude/longitude.
6
+ * This normalizer accepts ANY variant from the LLM and converts to what
7
+ * each specific endpoint expects.
8
+ *
9
+ * Also handles:
10
+ * - Default geometry="none" for MCP consumers (prevents context blow-up)
11
+ * - Removal of undefined/null optional params
12
+ */
13
+
14
+ /**
15
+ * Per-tool coordinate format expected by the backend.
16
+ * 'lat_lng' = { lat, lng }
17
+ * 'lat_lon' = { lat, lon }
18
+ * 'latitude_longitude' = { latitude, longitude }
19
+ */
20
+ const COORD_FORMAT = {
21
+ // lat/lng tools
22
+ identify_features_at_point: 'lat_lng',
23
+ get_environmental_data_near_point: 'lat_lng',
24
+ delineate_watershed: 'lat_lng',
25
+ get_watershed_characteristics: 'lat_lng',
26
+ get_flow_statistics: 'lat_lng',
27
+ find_similar_watersheds: 'lat_lng',
28
+ find_similar_watersheds_with_stats: 'lat_lng',
29
+ recommend_index_gage: 'lat_lng',
30
+ transfer_flood_statistics: 'lat_lng',
31
+
32
+ // lat/lon tools
33
+ get_rainfall_data: 'lat_lon',
34
+ get_idf_curves: 'lat_lon',
35
+ get_rainfall_distribution: 'lat_lon',
36
+ get_climate_change_factors: 'lat_lon',
37
+ get_rainfall_uncertainty_bounds: 'lat_lon',
38
+ get_hydrology_distribution_for_location: 'lat_lon',
39
+ get_watershed_for_point: 'lat_lon',
40
+
41
+ // latitude/longitude tools
42
+ generate_hyetograph: 'latitude_longitude',
43
+ export_hyetograph: 'latitude_longitude',
44
+ get_climate_change_rainfall_projection: 'latitude_longitude',
45
+ generate_uncertainty_envelope: 'latitude_longitude',
46
+ run_rainfall_sensitivity_analysis: 'latitude_longitude',
47
+ };
48
+
49
+ /**
50
+ * Tools that support a `geometry` parameter for controlling response size.
51
+ * For MCP consumers, default to "none" to prevent context window overflow.
52
+ */
53
+ const GEOMETRY_PARAM_TOOLS = new Set([
54
+ 'get_environmental_data_for_area',
55
+ 'get_environmental_data_near_point',
56
+ 'get_environmental_data_in_bbox',
57
+ 'get_layer_features',
58
+ ]);
59
+
60
+ /**
61
+ * Normalize parameters for a tool call.
62
+ *
63
+ * 1. Converts any lat/lng/lon/latitude/longitude variant to what the backend expects
64
+ * 2. Defaults geometry="none" for tools that support it (MCP context protection)
65
+ * 3. Strips undefined/null optional params
66
+ *
67
+ * @param {string} toolName - The legacy tool name (after action routing for consolidated tools)
68
+ * @param {object} params - Raw parameters from the LLM
69
+ * @returns {object} Normalized parameters
70
+ */
71
+ export function normalizeParams(toolName, params) {
72
+ if (!params || typeof params !== 'object') return params;
73
+
74
+ let p = { ...params };
75
+
76
+ // ── 1. Coordinate normalization ──────────────────────────────────
77
+ const format = COORD_FORMAT[toolName];
78
+ if (format) {
79
+ // Extract lat from any variant
80
+ const lat = p.lat ?? p.latitude;
81
+ // Extract lng from any variant
82
+ const lng = p.lng ?? p.lon ?? p.longitude;
83
+
84
+ // Remove all coordinate variants
85
+ delete p.lat;
86
+ delete p.latitude;
87
+ delete p.lng;
88
+ delete p.lon;
89
+ delete p.longitude;
90
+
91
+ // Set the correct format if we have values
92
+ if (lat !== undefined && lng !== undefined) {
93
+ switch (format) {
94
+ case 'lat_lng':
95
+ p.lat = Number(lat);
96
+ p.lng = Number(lng);
97
+ break;
98
+ case 'lat_lon':
99
+ p.lat = Number(lat);
100
+ p.lon = Number(lng);
101
+ break;
102
+ case 'latitude_longitude':
103
+ p.latitude = Number(lat);
104
+ p.longitude = Number(lng);
105
+ break;
106
+ }
107
+ }
108
+ }
109
+
110
+ // ── 2. Default geometry="none" for MCP ───────────────────────────
111
+ if (GEOMETRY_PARAM_TOOLS.has(toolName) && p.geometry === undefined) {
112
+ p.geometry = 'none';
113
+ }
114
+
115
+ // ── 3. Strip undefined/null optional params ──────────────────────
116
+ for (const [key, value] of Object.entries(p)) {
117
+ if (value === undefined || value === null) {
118
+ delete p[key];
119
+ }
120
+ }
121
+
122
+ return p;
123
+ }
@@ -7,21 +7,35 @@
7
7
  */
8
8
 
9
9
  const MAX_FEATURES = 50;
10
- const MAX_RESPONSE_CHARS = 100_000; // ~25K tokens
10
+ const MAX_RESPONSE_CHARS = 40_000; // ~10K tokens — keeps responses within LLM context budget
11
11
 
12
12
  /**
13
13
  * Tools that return feature collections and can be very large.
14
14
  * These get smart feature capping with layer summaries.
15
15
  */
16
16
  const FEATURE_HEAVY_TOOLS = new Set([
17
+ // Spatial queries
17
18
  'get_environmental_data_for_area',
18
19
  'get_environmental_data_near_point',
19
20
  'get_environmental_data_in_bbox',
20
21
  'get_layer_features',
22
+ 'query_address',
23
+ 'identify_features_at_point',
24
+ // Hydro/watershed
21
25
  'get_flowlines',
22
- 'find_water_features',
23
26
  'get_huc_watersheds',
27
+ 'get_watershed_water_quality',
28
+ 'delineate_watershed',
29
+ // Stations & water features
30
+ 'find_water_features',
24
31
  'find_monitoring_stations',
32
+ 'search_stations',
33
+ // Gage intelligence
34
+ 'get_storm_events',
35
+ 'find_similar_watersheds',
36
+ 'find_similar_watersheds_with_stats',
37
+ // Water quality
38
+ 'get_water_quality',
25
39
  ]);
26
40
 
27
41
  /**