@cyanheads/usgs-water-mcp-server
Query real-time and historical water data from ~8,000 USGS stream gages and groundwater wells via MCP. STDIO or Streamable HTTP.
9 Tools • 2 Resources
Overview
Real-time and historical water data from ~8,000 active USGS stream gages and groundwater wells across the US and territories. Find monitoring sites, pull the latest readings or a historical series, and see how today's flow compares with decades of records.
Tools
Resources
All resource data is also reachable via tools. Use water_find_sites for geographic site discovery.
Skill
Ships in skills/ for plugin installs, the npm package, the Docker image, and the .mcpb bundle, and is served over MCP as skill://usgs-water-guide/SKILL.md.
Capability reference
water_list_parameters tool
- Without
query: the curated, built-in list — no network call — of codes like 00060 (Discharge, ft³/s), 00065 (Gage height, ft), and 72019 (Depth to water level, ft), filtered by group (streamflow, groundwater, temperature, meteorological, water-quality, or all, the default)
- With
query: searches the full ~19,600-code USGS catalog by name or description (a bare 5-digit code returns that code) and returns at most 25 entries, each naming its source (curated or usgs-catalog), with total and truncated covering the full match set. query can't be combined with a group other than all
- The catalog is fetched on the first query and cached for 24 hours; a failed fetch fails as a retryable
upstream_error
water_find_sites tool
- Exactly one geographic scope per call —
bbox ("west,south,east,north"), 2-letter stateCd, up to 20 comma-separated 5-digit FIPS countyCd codes, or a 2- or 8-digit huc — narrowed by siteType (ST, GW, LK, SP, …), parameterCd, and hasDataTypeCd (iv / dv / gw). A missing or doubled scope fails as missing_major_filter / conflicting_major_filters before any NWIS call
limit (1–500, default 500) and offset page through the match set, with truncated and upstreamTotal describing what lies outside the window; siteOutput: expanded adds drainage and contributing area
- With
CANVAS_PROVIDER_TYPE=duckdb, a match set over 500 sites also stages in full to a canvas (canvas_id / table_name) for water_dataframe_query
water_get_readings tool
- Up to 100
sites per call, an optional parameterCd filter, and a period lookback (ISO 8601, default PT2H); returns one series per site, parameter, and sensor method (methodId / methodDescription), capped at 100 series per call (kept round-robin across sites) and the 10 most recent records per series, with totalSeries, totalValues, and truncated reporting what the caps held back
- Every value carries its qualifier codes (
P provisional, A approved), and sites NWIS returned nothing for are named in missingSites
water_get_series tool
- One
site and parameterCd per call over a startDate–endDate range; seriesType is daily (one value per day, the default) or instantaneous (~15 min). The most recent 500 records return inline, with totalRecords and truncated reporting the rest
- Returns one named series — by default the daily mean and the sensor method with the most records;
statCd (00003 mean, 00001 maximum, 00002 minimum; daily only) and methodId select another, and otherSeries lists every alternative with its record count
- With
CANVAS_PROVIDER_TYPE=duckdb, a range over 500 records spills the complete series to a canvas (canvas_id / table_name); pass a prior canvas_id to add the table to an existing canvas
water_get_site_data_catalog tool
- One
site, optional parameterCd and seriesType, and limit (1–1000; default 100). Reuse the same filters and limit with nextCursor until it is null. An unknown site fails as not_found; a known site with no matching metadata returns series: []
- Each row preserves its OGC
timeSeriesId, parameter name/unit, nullable statistic, sensor description, raw computation fields, and full UTC beginDate/endDate. Daily maps to daily; Points with Instantaneous maps to instantaneous; other computations have null seriesType
- For
water_get_series, take YYYY-MM-DD from the bounds, pass statCd only for daily rows, and select methodId from the series response, never an OGC ID. Bounds may contain gaps and lag observations; null bounds remain unknown
- Site identity and metadata share a 45-second deadline including retries. Malformed or rejected continuation cursors fail as
invalid_cursor; restart without cursor
water_get_conditions tool
- One
site and parameterCd; ranks the current instantaneous reading against the period-of-record daily-mean percentiles for its calendar day as a percentileClass — record-high (≥p95), above-normal, normal, below-normal, low, record-low (<p05), or unknown for a no-data reading — with percentileLabel stating the threshold in plain language and comparisonBasis noting that the ranking is approximate, not a flood-stage or drought determination
- When percentiles can't be applied, the reading still returns with
historicalContext: null and a historicalContextStatus of no_record, no_matching_day, no_matching_method, or unavailable (a transient stat-service failure); on a multi-sensor site, methodId and methodMatched name the sensor read and whether the percentiles came from its own series
water_dataframe_describe tool
- Lists the tables staged on a canvas by
water_get_series or water_find_sites, with each column's name, DuckDB type, and nullability — call it before water_dataframe_query or water_dataframe_drop to confirm the exact names
row_count is a DuckDB estimate and may differ from the exact count
water_dataframe_query tool
- A single read-only
SELECT against the staged tables; other statements, system-catalog access (information_schema, pg_catalog, duckdb_*), engine-setting functions, dynamic SQL, and file references are rejected. query() accepts a single SQL string literal, checked under the same restrictions
- Capped at 10,000 rows per query;
truncated: true signals more matched — narrow with WHERE/LIMIT, or run SELECT COUNT(*) for the true total
water_dataframe_drop tool
- Removes one staged table by
table_name (matched case-insensitively) from the canvas named by canvas_id; the canvas and its other tables stay, and the response reports the dropped table's row_count and the remaining_tables
- A name not staged on the canvas fails as
table_not_found and changes nothing, so a repeated drop is safe
- Opt-in: callable only with
WATER_DATAFRAME_DROP_ENABLED=true; otherwise it appears on the landing page as a disabled tool and is absent from tools/list. Like the other dataframe tools, it needs CANVAS_PROVIDER_TYPE=duckdb
usgs-water://site/{siteId} resource
- Returns
application/json site metadata: name, coordinates, type, HUC watershed code, state, county, drainage area, and altitude
siteId is an 8–15 digit USGS site number — discover one via water_find_sites
usgs-water://parameters resource
- The curated table of well-known parameter codes as
application/json — the list water_list_parameters returns without a query
- Takes no parameters; a curated subset of the USGS catalog — search the rest with
water_list_parameters query
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
USGS Water-specific:
- Wraps NWIS IV (instantaneous), DV (daily), site, and stat endpoints plus USGS Water Data parameter codes, monitoring locations, and time-series metadata — no API key required, fully public
- Input formats are checked at the edge against what NWIS actually accepts — site numbers, parameter codes, ISO 8601 periods, HUC, FIPS county, state, and bbox each carry a validated pattern advertised in the tool's JSON Schema
- HTML error detection: NWIS returns 400 with an HTML body for bad input, and the service layer extracts NWIS's own message and maps it to a typed failure
- DataCanvas spillover:
water_get_series (long date ranges) and water_find_sites (match sets past the 500-site cap) stage the full result as a DuckDB-backed table, queryable via water_dataframe_query; the spillover and the dataframe tools require CANVAS_PROVIDER_TYPE=duckdb, and water_dataframe_drop also needs WATER_DATAFRAME_DROP_ENABLED=true
- No-data readings stay no-data: a value NWIS reports as
-999999 (a seasonal, discontinued, dry, or malfunctioning gage) returns as an empty value with qualifiers naming why (Ssn, Dis, Dry, Eqp) — never as a magnitude — and stages as NULL on a canvas
- Groundwater depth reads through the standard IV service via parameter
72019 — the legacy gwlevels endpoint was decommissioned November 2025
Agent-friendly output:
- Percentile classification:
water_get_conditions returns a percentileClass callers can act on directly, paired with a percentileLabel that states the threshold in plain language
- Partial success over hard failure:
water_get_readings returns the series it got and names the rest in missingSites; water_get_conditions separates an empty stat table (no_record / no_matching_day) from a failed stat call (unavailable, transient) instead of collapsing both into one error
- Truncation signals: every capped response (
water_get_series, water_find_sites, water_get_readings) reports its own count and a truncated flag, plus canvas_id / table_name when the rest is retrievable via SQL
- Structured content and rendered text agree — every cap and count a tool applies appears identically in
structuredContent and in the markdown
Getting started
Connect to the hosted instance, no install:
Or run it locally:
For other clients, add this to the MCP config file:
DataCanvas is enabled by default for SQL analytics over large result sets; set CANVAS_PROVIDER_TYPE=none to return inline previews only. No API key is required — USGS NWIS is a free, public API. For Docker or a source build, see Running the server.
Configuration
See .env.example for the full list of optional overrides.
Running the server
Local development
Requires Bun v1.4.0 or higher (or Node.js v24+).
-
Clone and install:
-
Build and run:
-
Run checks and tests:
Docker
The image defaults to HTTP transport, stateless session mode, and logs to /var/log/usgs-water-mcp-server. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/usgs-water-mcp-server:latest"]. To build locally, docker build -t usgs-water-mcp-server .; OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
Project structure
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catch in tool logic
- Use
ctx.log for request-scoped logging, ctx.state for tenant-scoped storage
- Register new tools and resources via the barrels in
src/mcp-server/*/definitions/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Contributing
Issues are welcome. Run checks and tests before submitting:
License
Apache-2.0 — see LICENSE for details.