mcp-epa-echo
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-epa-echoget recent violations for facility ABC"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 requiredRelated MCP server: Echo MCP Server
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 |
| "Which facility is this?" |
| Returns |
| "What's permitted to discharge in this area?" |
| Results are permit addresses, not outfall locations — pair with |
| "What are the current effluent limits?" |
| 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. |
| "What was actually measured/discharged?" |
| Pass a date range to reach historical permits — omitting it returns only the current window, and the response always says so. |
| "Did this facility exceed its limits or miss a report?" |
| Same windowing trap and same fix as |
| "Was there a formal consent order or penalty?" |
| Federal |
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.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/higherpass/mcp-epa-echo'
If you have feedback or need assistance with the MCP directory API, please join our Discord server