Skip to main content
Glama
higherpass

mcp-epa-echo

by higherpass

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:

  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

npm install
npm run build   # compiles TypeScript to dist/
npm test        # 152 tests, no network access required

Related 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. 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 nameRegistryID, 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_facilitiesp_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

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.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

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

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