mcp-usgs-water-data
# mcp-usgs-water-data
An [MCP](https://modelcontextprotocol.io) server that exposes the [USGS Instantaneous
Values (IV) web service](https://waterservices.usgs.gov/docs/instantaneous-values/instantaneous-values-details/)
as tools — real-time and historical streamflow, gage height, water temperature, and
related measurements from USGS gauges across the United States.
It is built for a language model to use safely. The USGS API is designed for browsers and
bulk downloads; feeding its responses straight to a model goes wrong in ways that are easy
to miss and hard to notice — a state-wide query returns megabytes of JSON, a "latest value"
can be seven years old, and a river with no gauge returns an empty result the model will
invent a reason for. This server exists to turn those traps into structured, self-describing
answers.
## What it does
Three tools:
| Tool | Purpose |
|------|---------|
| **`find_sites`** | Resolve a place or river name to USGS station numbers. Start here when you know the river but not its 8- or 15-digit site number. |
| **`get_instantaneous_values`** | Fetch readings for one or more sites. The main tool. |
| **`list_common_parameter_codes`** | Look up the 5-digit USGS code for a measurement (e.g. streamflow = `00060`). No network call. |
The usual flow is `find_sites` → `get_instantaneous_values`. A model asking "what's the flow
of the Chattahoochee in Atlanta?" resolves the name to site `02336000`, then reads it.
## Requirements
- **Node.js 22+** (uses native `fetch` with transparent gzip; no HTTP dependencies).
## Install
```bash
npm install
npm run build # compiles TypeScript to dist/
npm test # 189 tests, no network access required
```
## Configure your MCP client
The server speaks MCP over **stdio**. Point your client at the built entry file.
**Claude Code** (project-scoped, committable):
```bash
claude mcp add usgs-water --scope project -- node /absolute/path/to/dist/src/index.js
```
This writes `.mcp.json`. Project-scoped servers are never auto-trusted — restart `claude`
in the project directory and approve the server once when prompted.
**Any MCP client**, directly:
```json
{
"mcpServers": {
"usgs-water": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/dist/src/index.js"]
}
}
}
```
> The config runs `dist/`, not the TypeScript source. **Run `npm run build` after any code
> change**, or the server will keep running the old build.
## Tools
### `get_instantaneous_values`
Exactly **one** major filter is required to scope the query — providing zero or more than one
is rejected before any network call:
| Filter | Type | Notes |
|--------|------|-------|
| `sites` | `string[]` | Station numbers, e.g. `["01646500"]`. Up to 100. Not river names — use `find_sites`. |
| `stateCd` | `string` | Two-letter state code, e.g. `"NY"`. |
| `countyCd` | `string[]` | 5-digit FIPS codes, e.g. `["24031"]`. Up to 20. |
| `huc` | `string[]` | Hydrologic Unit Codes: one 2-digit region, or up to ten 8-digit sub-basins. |
| `bBox` | `object` | `{ west, south, east, north }` in decimal degrees. Longitudes are negative in the US. Area capped at 25 square degrees. |
Optional narrowing: `parameterCd` (which measurements), `siteType`, `siteStatus`,
`modifiedSince`, `agencyCd`.
**Time window** — pick one, they are mutually exclusive:
- *(none)* — the single latest value per sensor.
- `period` — an ISO-8601 duration ending now, e.g. `"P7D"` (last 7 days), `"PT6H"` (last 6 hours).
- `startDT` / `endDT` — an absolute range, e.g. `"2024-01-01"` or `"2024-01-01T12:00"`. `endDT`
requires `startDT`. Data begins **2007-10-01**.
**Output**:
- `mode` — `"values"` (default; individual readings) or `"summary"` (count, min, max, mean,
first, last). **Use `"summary"` for any window longer than a day** — a single site over one
year is ~35,000 readings.
- `maxValues` — cap on readings *per series* in `"values"` mode (default 200, max 1000).
### `find_sites`
Same one-major-filter rule (`sites` / `stateCd` / `countyCd` / `huc` / `bBox`), plus:
- `nameContains` — case-insensitive substring match on the station name. Applied **client-side**
after fetching by the major filter, so it narrows the *results*, not the request. A `stateCd`
query can return hundreds of sites before filtering.
- `siteType`, `siteStatus` (default `"active"`), `hasDataTypeCd` (default `"iv"` — only sites
that report instantaneous values), `maxSites` (default 50, max 500).
### `list_common_parameter_codes`
No input. Returns the table below.
| Code | Measurement | Unit |
|------|-------------|------|
| `00060` | Discharge (streamflow) | ft³/s |
| `00065` | Gage height | ft |
| `00010` | Water temperature | °C |
| `00095` | Specific conductance | µS/cm @25°C |
| `00300` | Dissolved oxygen | mg/L |
| `00400` | pH | std units |
| `63680` | Turbidity | FNU |
| `00045` | Precipitation | in |
| `72019` | Depth to water level | ft |
| `62614` | Lake/reservoir elevation (NGVD 1929) | ft |
| `62615` | Lake/reservoir elevation (NAVD 1988) | ft |
| `00020` | Air temperature | °C |
| `00025` | Barometric pressure | mm Hg |
| `00035` | Wind speed | mph |
| `00036` | Wind direction | degrees |
| `00052` | Relative humidity | % |
| `00055` | Stream velocity (point) | ft/s |
## Reading the output
The response has a top-level `series` array. Each series carries the site, the variable, the
sensor `method`, qualifier codes, and either readings or a summary. A few fields exist
specifically to prevent a confidently wrong answer, and a consumer should check them.
**On each series:**
- **`stale`** (`true` / `false` / `null`) — `true` means the latest reading is more than 48
hours behind the query's reference time. USGS returns decommissioned sensors in a
latest-value query, frozen at their final reading — a temperature sensor switched off in
2019 will otherwise look current. `null` means staleness could not be evaluated. **Check
this before reporting any value as current.**
- **`discontinued`** — the sensor is marked decommissioned in its metadata. A hint that
explains *why* a series is stale; not itself a freshness signal.
- **`truncated` / `totalValues`** — set when `maxValues` capped a series. You are seeing the
most recent N, not the whole record.
- **qualifiers** — most recent USGS data is provisional (code `P`) and subject to revision.
These are never stripped.
**On the response (not the series):**
- **`missingParameterCodes`** — present when you requested a `parameterCd` a site does not
measure. Series are **not** positionally aligned with the codes you asked for; match on each
series' `variable.code`.
- **`note`** — a plain-language explanation attached to empty results, stale series, or
dropped parameters, so an empty or partial answer is never silently ambiguous.
When a response would exceed 256 KB even after shaping, the server returns an error naming
`summary` mode rather than truncating silently.
## Rate limiting
USGS publishes no numeric rate limit but blocks IP addresses it judges to be "seriously
impacting others." A single client session can fan out — subagents issuing many concurrent
tool calls all route through this one server process on one IP — so the server gates its own
outbound traffic: a shared limiter with a concurrency cap, minimum spacing between requests,
and a load-shedding valve that returns a "retry shortly" error rather than hanging when a
burst overwhelms it. There is deliberately **no retry-on-failure loop** — retrying into a
throttling server is how you earn the block.
Three environment variables, read once at startup:
| Variable | Default | Meaning |
|----------|---------|---------|
| `USGS_MAX_CONCURRENCY` | `4` | Requests in flight at once. |
| `USGS_MIN_INTERVAL_MS` | `75` | Minimum gap between request starts (~13/s ceiling). |
| `USGS_MAX_QUEUE_WAIT_MS` | `45000` | Shed a request that would wait longer than this. |
Tune concurrency and spacing **up** for a purely interactive deployment, **down** for a
scheduled batch collector. Negative and non-numeric values fall back to the default. Zero
falls back for concurrency and queue-wait (both are degenerate at `0` — a zero concurrency
cap deadlocks, a zero queue-wait sheds every request), but `0` is a valid "no added spacing"
setting for the interval.
The upstream base URLs are not currently configurable; they are constants in `src/usgs.ts`
and `src/sites.ts`.
## Scope and limits
- **No site lookup by coordinates-you-assert.** `find_sites` resolves names and geographic
filters; a bounding box you get wrong will still return an honest (possibly empty) result.
- **Groundwater-only filters** (aquifer codes, well/hole depth) are not exposed. Additive if
needed.
- **`503` is not retried.** The gate prevents the burst that causes throttling; it does not
retry after one occurs.
- **One documented staleness corner**: a future `endDT` combined with a payload missing its
request timestamp marks live data as stale. Unobserved in practice, errs toward caution.
Design rationale for every one of these decisions — and the live-service behavior that drove
them — is in [`DESIGN.md`](./DESIGN.md).
## Development
```bash
npm run build # tsc -> dist/
npm test # build, then node --test (no network)
```
Tests are deterministic and hermetic: no live network calls, no wall-clock or timezone
dependence (the suite passes identically under any `TZ`). Fixtures under `test/fixtures/` are
real captured USGS responses, not synthesized.
## License
[MIT](./LICENSE). USGS water data is in the public domain; this license covers the server
code only.
TDQS
Scored across 3 tools
The three tools have clearly distinct purposes: get_instantaneous_values fetches readings, find_sites resolves place names to station numbers, and list_common_parameter_codes maps common measurements to parameter codes. There is minor potential confusion between find_sites and get_instantaneous_values since both share the same filter options (sites/stateCd/huc/bBox/countyCd), but their distinct roles (lookup vs. data retrieval) are well documented.
The names mix conventions: get_instantaneous_values and list_common_parameter_codes use verb_object patterns but get_instantaneous_values is awkwardly verbose while find_sites and the others are not consistently phrased. There's no consistent verb (get, list, find) and no consistent noun form, though all are readable snake_case verbs followed by a noun.
Three tools form a tight, well-scoped workflow: resolve sites, look up parameter codes, then fetch values. Each tool is essential and earns its place with no obvious redundancy. The tight coupling (find_sites feeds get_instantaneous_values, list_common_parameter_codes feeds parameterCd) justifies exactly these three.
The core workflow of finding sites, mapping parameters, and retrieving instantaneous values is fully covered. Minor gaps exist such as no support for daily values or other USGS services (which are out of scope given the server name), and the user must manually chain the three tools, but within the stated 'instantaneous values' domain the surface is complete.