mcp-epa-echo
An MCP server that exposes EPA ECHO water quality data as tools, enabling facility search, permit limits, discharge measurements, violations, and enforcement actions.
README
mcp-epa-echo
An MCP server that exposes the EPA ECHO (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:
- The QID two-step. Every ECHO search (
get_facilities,get_cases, ...) returns aQueryIDyou must re-fetch withget_qidto get actual rows. Every tool here does that internally — one call in, rows out. - 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.tshides all of it: pass a name or an NPDES permit number, get a facility back. - 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. - 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
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:
{
"mcpServers": {
"epa-echo": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/dist/src/index.js"],
"env": {}
}
}
}
The config runs
dist/, not the TypeScript source. Runnpm run buildafter 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_qidreturns a DEFAULT column set that silently OMITS fields callers read by name —RegistryID,FacLong,CWPMajorMinorStatusFlag, andCWPTotalPenaltieson 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 returningregistryId: null/lon: nullfor every real facility. You must passqcolumns(a comma-separated list of ECHO column IDs) on everyget_qidcall to get them. This server does, via the sharedCWA_FACILITY_QCOLUMNS/CASE_QCOLUMNSconstants — 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. Notp_id,p_permit,p_permitnumber, orp_npdesid— all of those silently fall through to ECHO's whole-database row-limit error. - City filter:
p_ct. Notp_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_idoncase_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. ExceedencePctcarries a literal%in the string (e.g."32%"), not a bare number —Number("32%")isNaN. Parsed correctly here; worth knowing if you ever touch ECHO's raw JSON directly.- Case penalties are frequently
$0in the federalFedPenaltyfield even for real cases — many enforcement actions are state-level, and the real penalty is inStateLocPenaltyAmtinstead. 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_chartcalled withoutstart_date/end_datereturns only the current permit window (verified: a facility with a 2023–2026 permit and zero current discharges returns emptyPermFeatures, 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_qidhas no default page cap observed up to at least 1815 rows with nopageno/responsesetsupplied —find_facility,facilities_near, andget_enforcement_actions(the tools that go throughget_qid) sourcetotalMatchedfrom the actual rows returned rather than the summaryQueryRowsfield, and every live check so far has shown the two agreeing.
Development
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 for the full design rationale and the field research behind it.
License
MIT.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.