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: 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. 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.

Available Tools

1 tool
get_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.

ParametersJSON Schema
NameRequiredDescriptionDefault
nameNoFacility name, resolved to an NPDES permit internally. Prefer npdesId when you have it - name resolution can match more than one facility.
endDateNoEnd of the DMR date range, YYYY-MM-DD. Requires startDate.
npdesIdNoNPDES 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.
parameterNoPollutant parameter name or code to filter to (e.g. a DMR parameter code). Omit to return all measured parameters.
startDateNoStart of the DMR date range, YYYY-MM-DD. Omitting both startDate and endDate returns only the CURRENT permit window.
maxResultsNo

TDQS

A4.8/5.0
Behavior5/5

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.

Conciseness4/5

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.

Completeness5/5

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.

Parameters4/5

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.

Purpose5/5

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.

Usage Guidelines5/5

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. 1 tool updatev0.1.0
    • First observedget_dmr_values

TDQS

A4.4/5.0
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
ResponsivenessSyncing

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

Related MCP Servers

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