mcp-epa-echo
by higherpass
README.md
# mcp-epa-echo
An [MCP](https://modelcontextprotocol.io) server that exposes the [EPA ECHO](https://echo.epa.gov)
(Enforcement and Compliance History Online) water-quality data as tools — NPDES-permitted
facility search, permit limits, measured discharges, compliance violations, and formal
enforcement cases.
It is the ECHO sibling of `mcp-usgs-water-data` and follows the same
philosophy: ECHO's raw API is built for browsers and bulk downloads, and handing its responses
straight to a model goes wrong in specific, easy-to-miss ways. This server collapses four of
those traps into single, self-describing tool calls:
1. **The QID two-step.** Every ECHO search (`get_facilities`, `get_cases`, ...) returns a
`QueryID` you must re-fetch with `get_qid` to get actual rows. Every tool here does that
internally — one call in, rows out.
2. **ID soup.** A facility has an NPDES permit number, a separate Registry ID, and (for
enforcement cases) is looked up by yet another param keyed on the Registry ID. `resolve.ts`
hides all of it: pass a name or an NPDES permit number, get a facility back.
3. **Deeply nested effluent JSON.** The raw DMR (Discharge Monitoring Report) response buries
values under `Results → PermFeatures → Parameters → DischargeMonitoringReports`. Flattened
here to one tidy row per outfall × parameter × report.
4. **The windowing trap.** Calling ECHO's effluent-chart endpoint *without* a date range
silently returns only the facility's **current** permit window — which can start as
recently as 2023, making older discharges and consent orders invisible with no error, no
warning, just an emptier-than-expected answer. Every tool that can hit this trap says so
explicitly, every time, whether or not the current window happens to have rows.
## Install / build / test
```bash
npm install
npm run build # compiles TypeScript to dist/
npm test # 152 tests, no network access required
```
## Configure your MCP client
The server speaks MCP over **stdio**. Point your client at the built entry file — `.mcp.json`
in this repo already does:
```json
{
"mcpServers": {
"epa-echo": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/dist/src/index.js"],
"env": {}
}
}
}
```
> The config runs `dist/`, not the TypeScript source. **Run `npm run build` after any code
> change**, or the server keeps running the old build.
No API key or authentication is required — ECHO's web services are public EPA data.
## The 6 tools
| Tool | Answers | Key inputs | The one thing to know |
|------|---------|------------|------------------------|
| **`find_facility`** | "Which facility is this?" | `name`, `city`+`state`, or `npdesId` | Returns `confirmed:false` (not an error) for an npdesId ECHO couldn't verify — the id you passed is still returned. |
| **`facilities_near`** | "What's permitted to discharge in this area?" | `bBox` **or** `lat`+`lon`+`radiusMiles` (exactly one) | Results are permit *addresses*, not outfall locations — pair with `get_dmr_values` to see what's actually discharged. |
| **`get_permit_limits`** | "What are the current effluent limits?" | `npdesId` or `name`; optional `outfall`, `startDate`/`endDate` | A parameter can carry both a mass limit (lb/d) and a concentration limit (mg/L) at the same statistical basis — both are returned, not deduped away. |
| **`get_dmr_values`** | "What was actually measured/discharged?" | `npdesId` or `name`; optional `parameter`, `startDate`/`endDate` | **Pass a date range to reach historical permits** — omitting it returns only the current window, and the response always says so. |
| **`get_violations`** | "Did this facility exceed its limits or miss a report?" | `npdesId` or `name`; optional `startDate`/`endDate` | Same windowing trap and same fix as `get_dmr_values`. Distinguishes reporting/overdue violations from numeric effluent violations. |
| **`get_enforcement_actions`** | "Was there a formal consent order or penalty?" | `npdesId` or `name`; optional `startDate`/`endDate` | Federal `penalty` is frequently `$0` even for real cases — check `stateLocalPenalty` too. Distinct from `get_violations`: a facility can have violations with no case, or a case spanning years of violations. |
Every facility-taking tool accepts **`npdesId` OR `name`**, never both — resolution happens
internally (`resolve.ts`), and an ambiguous name returns a note asking you to call
`find_facility` first to get a specific `npdesId`.
Every tool that returns a list caps results at `maxResults` (default 50, max 500) and reports
`truncated` / `totalMatched` so a capped response is never mistaken for the complete set.
### `find_facility`
At least one identifying filter is required: `name`, `city` + `state` together, or `npdesId`.
`city` alone is rejected (too broad to identify a facility). `nameContains` is a **separate,
client-side** substring filter applied after ECHO's own server-side name/city search — it
narrows, it does not broaden a search that came back empty. A name/city search can match more
than one facility; results are never guessed down to one, and the response's `note` says when
they're ambiguous. `permitStatus` (`"Effective"`, `"Terminated"`, `"Expired"`, ...) is returned
per facility — appearing in results does not mean a facility currently holds an active permit.
### `facilities_near`
Exactly one spatial filter: `bBox` (`{west, south, east, north}` in decimal degrees) **or**
`lat` + `lon` + `radiusMiles` together (radius capped at 25 miles). Zero, both, or a partial
point set is rejected before any network call. Results include majors **and** minors — a dense
urban area within even a 2-mile radius can return hundreds of stormwater/general-permit
facilities.
### `get_permit_limits`
Omit `startDate`/`endDate` for the facility's **current** permit limits (the common case). A
permit reissuance changes limits, so a past date range returns a *prior* permit's limits, not
the current one. Rows are deduped to the distinct limit set — one row per (outfall, parameter,
statistical basis, limit type), since the raw data has one row per monitoring *report* (e.g. 24
monthly reports sharing the same limit). `limitValue:null` means monitoring is required but no
numeric limit is assigned — not "no data."
### `get_dmr_values`
**The trap, solved.** Omitting `startDate`/`endDate` returns only the current permit window,
and the response always notes this — even when current-window rows come back, so a populated
response is never mistaken for the full history. Passing a date range routes the *same*
underlying ECHO call (`eff_rest_services.get_effluent_chart` with `start_date`/`end_date`) into
returning real historical data from older permits — there is no separate historical endpoint.
`parameter` filters by exact code (`"00530"`) or a case-insensitive name substring
(`"suspended"`). `dmrValue:null` with a populated `nodiFlag` means ECHO recorded a specific
no-data reason (e.g. "monitoring not required"); with `nodiFlag:null` too, the value is simply
missing. Values may be provisional and subject to revision.
### `get_violations`
Same windowing trap, same fix, as `get_dmr_values` — reuses its exact note logic rather than a
forked copy. A row appears only when ECHO's own compliance determination
(`NPDESViolations`) flagged it — a NODI code alone is never treated as a violation. Two kinds
show up, distinguished by `violationCode`/`severity`: reporting/monitoring violations (e.g.
`D80`, a report submitted late or not at all) and effluent/numeric violations (e.g. `E90`, a
reported value that exceeded its limit; `exceedancePct` is populated when ECHO computed one). If
a date range returns rows but none are violations, that's reported as a genuinely clean result,
not an error or missing data.
### `get_enforcement_actions`
Looked up by the facility's **ECHO Registry ID**, not its NPDES permit number — resolved
internally the same way `find_facility` resolves an `npdesId` or `name`. If the Registry ID
couldn't be confirmed, this tool says so rather than guessing. Formal ICIS-NPDES cases: consent
orders, administrative orders, penalties. **Not** the same record as `get_violations` — no 1:1
relationship either direction. `startDate`/`endDate` filter client-side against either
`dateFiled` or `settlementDate` (ECHO's case search has no server-side date filter); a case with
no date on file at all is excluded, not silently included, when a range is given.
## ECHO quirks (hard-won, don't re-learn these)
Everything below was verified against live ECHO responses, not documentation — several
plausible-looking parameter names silently no-op (return the whole database, or an unrelated
empty result) instead of erroring, so getting these wrong fails quietly.
- **`get_qid` returns a DEFAULT column set that silently OMITS fields callers read by
name** — `RegistryID`, `FacLong`, `CWPMajorMinorStatusFlag`, and `CWPTotalPenalties` on
facility rows; `StateLocPenaltyAmt` (and others) on case rows. It does not error — those
fields just come back missing, indistinguishable from a genuinely null value, so a tool can
look like it's working while quietly returning `registryId: null` / `lon: null` for every
real facility. You must pass `qcolumns` (a comma-separated list of ECHO column IDs) on every
`get_qid` call to get them. This server does, via the shared `CWA_FACILITY_QCOLUMNS` /
`CASE_QCOLUMNS` constants — single-sourced so every call site stays in sync. This was the
single most expensive lesson of this build: it passed every offline test (fixtures were
captured WITH qcolumns) and only broke live.
- **Exact NPDES permit lookup**: `p_pid`. Not `p_id`, `p_permit`, `p_permitnumber`, or
`p_npdesid` — all of those silently fall through to ECHO's whole-database row-limit error.
- **City filter**: `p_ct`. Not `p_city` (also silently ignored).
- **Name filter**: `p_fn` — a genuine server-side substring match, not a broader "any word"
match.
- **Spatial bounding box**: ECHO has a native bbox filter on `cwa_rest_services.get_facilities`
— `p_c1lat`/`p_c1lon` (southwest corner) + `p_c2lat`/`p_c2lon` (northeast corner). No
lat/long/radius approximation needed.
- **Point-radius search**: `p_lat` / `p_long` / `p_radius` (miles).
- **Enforcement case → facility link**: `p_facility_id` on `case_rest_services.get_cases`,
and the value it wants is the **Registry ID**, not the NPDES permit number. `p_id`, `p_pid`,
`p_reg`, `p_facid`, and several other guesses all silently no-op here too.
- **`ExceedencePct` carries a literal `%`** in the string (e.g. `"32%"`), not a bare number —
`Number("32%")` is `NaN`. Parsed correctly here; worth knowing if you ever touch ECHO's raw
JSON directly.
- **Case penalties are frequently `$0` in the federal `FedPenalty` field** even for real cases
— many enforcement actions are state-level, and the real penalty is in `StateLocPenaltyAmt`
instead. Both are surfaced (`penalty` / `stateLocalPenalty`); checking only one can make a
facility's entire enforcement history look penalty-free when it wasn't.
- **The windowing trap is real and undocumented**: `eff_rest_services.get_effluent_chart`
called without `start_date`/`end_date` returns only the current permit window (verified: a
facility with a 2023–2026 permit and zero current discharges returns empty `PermFeatures`,
even though it has real 2011-era historical DMR data). Passing the date range through is the
entire fix — there is no separate historical endpoint.
- **`get_qid` has no default page cap** observed up to at least 1815 rows with no
`pageno`/`responseset` supplied — `find_facility`, `facilities_near`, and
`get_enforcement_actions` (the tools that go through `get_qid`) source `totalMatched` from
the actual rows returned rather than the summary `QueryRows` field, and every live check so
far has shown the two agreeing.
## Development
```bash
npm run build # tsc -> dist/
npm test # build, then node --test (no network)
```
Tests are hermetic: no live network calls. Fixtures under `test/fixtures/` are real captured
ECHO responses (a handful of specific rows are disclosed as hand-edited via an inline
`_fixtureNote` where a live example didn't surface one), not synthesized from scratch. See
[`DESIGN.md`](./DESIGN.md) for the full design rationale and the field research behind it.
## License
[MIT](./LICENSE).
TDQS
A4.4/5.0
Scored across 1 tool
Disambiguation5/5
With only one tool, there is no risk of confusion between tools. The single tool's purpose is clear and distinct.
Naming Consistency5/5
The single tool uses a clear snake_case verb_noun pattern ('get_dmr_values'), which is consistent with itself.
Tool Count2/5
A single tool for what appears to be a complex domain (EPA ECHO data) is insufficient. Most well-scoped servers have 3-15 tools to cover basic operations.
Completeness2/5
The tool provides access only to DMR values, but the server name suggests a broader scope (EPA ECHO). Missing tools for facility search, permits, compliance, etc., are notable gaps.
Maintenance
ActivitySlowing
ResponsivenessNo issues