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: socrata-mcp
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.
Available Tools
1 toolget_dmr_valuesA
Get measured Discharge Monitoring Report (DMR) values for a facility - the actual reported effluent values, not just limits (use get_permit_limits for that).
Identify the facility with npdesId (preferred) or name (resolved internally - ambiguous names return a note asking you to call find_facility first).
THE WINDOWING TRAP, SOLVED: omitting startDate/endDate returns ONLY the
facility's CURRENT permit window - which can be as narrow as the last few
years, hiding older discharges entirely. The response ALWAYS notes this when
no date range is given, even if current-window rows come back. To reach
historical discharges (e.g. a pre-2023 permit), pass startDate/endDate
(YYYY-MM-DD) - ECHO honors these and returns REAL DATA from the facility's
OLDER permit(s), verified live (a 2011-2012 query against a real facility
returned 360 real historical report rows). There is no separate historical
endpoint to call - passing the date range through to this same tool IS the
fix.
If a date range IS given but zero rows come back, the response explains the returned permit window rather than implying "no discharge occurred" - a range outside any permit ECHO has data for looks identical to a genuinely clean period unless you check the window ECHO actually returned.
dmrValue:null with a populated nodiFlag means ECHO recorded a specific
reason no value was reported (e.g. monitoring not required that period) -
this is NOT the same as a missing/unreported value; nodiFlag:null with
dmrValue:null on an overdue report means the value is simply missing.
Values may be provisional and subject to revision.
Optionally filter with parameter - matches EITHER an exact parameter code
(e.g. "00530") OR a case-insensitive substring of the parameter name (e.g.
"suspended"). Results are capped at maxResults (default 50, max 500);
when more match, truncated:true and totalMatched show how many were
found in total.
| Name | Required | Description | Default |
|---|---|---|---|
| name | No | Facility name, resolved to an NPDES permit internally. Prefer npdesId when you have it - name resolution can match more than one facility. | |
| endDate | No | End of the DMR date range, YYYY-MM-DD. Requires startDate. | |
| npdesId | No | NPDES permit number (e.g. "GA0038202"). Loosely 2-letter state code + up to 7 alphanumeric characters, but not strictly validated here - pass it exactly as find_facility returned it. | |
| parameter | No | Pollutant parameter name or code to filter to (e.g. a DMR parameter code). Omit to return all measured parameters. | |
| startDate | No | Start of the DMR date range, YYYY-MM-DD. Omitting both startDate and endDate returns only the CURRENT permit window. | |
| maxResults | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden. It thoroughly explains key behaviors: the 'windowing trap' with date ranges, handling of zero results, interpretation of null dmrValue with nodiFlag, parameter matching, and result truncation. All behavioral traits are disclosed.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear paragraphs and warnings. It is somewhat lengthy but each section adds value with no redundancy. Could be slightly more concise, but overall effective.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the complexity (6 parameters, no output schema, no annotations, no siblings), the description is remarkably complete. It covers not only the primary function but edge cases (ambiguous names, zero results, null dmrValue), alternative tools, and behavioral nuances. No gaps identified.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is high (83%), but the description adds significant meaning beyond the schema: the windowing trap, the interaction between startDate/endDate, the substring matching behavior for parameter, and the meaning of nodiFlag. It adds value, though the schema already documents basics.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states it gets measured DMR values, not limits, and explicitly distinguishes from get_permit_limits. The verb 'get' and resource 'DMR values' are specific, and the distinction from the sibling is clear.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit when-to-use (getting actual effluent values) and when-not-to-use (for limits, use get_permit_limits). It also gives detailed guidance on facility identification (prefer npdesId, name resolution with fallback), date range usage, filtering, and result limits.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
1 tool update
v0.1.0- First observed
get_dmr_values
TDQS
With only one tool, there is no risk of confusion between tools. The single tool's purpose is clear and distinct.
The single tool uses a clear snake_case verb_noun pattern ('get_dmr_values'), which is consistent with itself.
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.
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
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
EPA ECHO MCP — wraps EPA ECHO Web Services (free, no auth)
- earthOAuthcom.mireye
MCP server for Mireye Earth — federal-source-cited geospatial data for any MCP-aware agent.
EPA Emissions MCP — wraps EPA Envirofacts REST API (free, no auth)
MCP server for US nursing facility search and ownership lookup (NursingHomeDatabase).
Related MCP Servers
- AlicenseBqualityBmaintenanceThis is an MCP (Model Context Protocol) server that provides tools for interacting with the NOAA Tides and Currents API.241176MIT
- FlicenseNot gradedqualityDmaintenanceAn MCP server that provides tools to search, query, and aggregate data from any Socrata open data portal.-
- AlicenseAqualityBmaintenanceAn MCP server that provides echo resource, tool, and prompt functionality for testing and demonstration.9191MIT
- AlicenseNot gradedqualityAmaintenanceAccess EPA environmental data — facility compliance (ECHO), toxic releases (TRI), Superfund sites, drinking water systems, environmental justice screening (EJScreen), and real-time air quality (AirNow) via MCP.1721Apache 2.0
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- 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