@cyanheads/epa-drinking-water-mcp-server
Search EPA drinking water systems, violations, enforcement actions, and contaminant measurements via MCP. STDIO or Streamable HTTP.
9 Tools
Overview
EPA drinking-water records from SDWIS DMAP and the UCMR 5 publication. Resolve water systems, inspect historical violations and enforcement, and retrieve Lead and Copper Rule summaries or unregulated-contaminant occurrence measurements. Runs over stdio or Streamable HTTP without an API key.
Tools
The dataframe tools require npm or Docker with CANVAS_PROVIDER_TYPE=duckdb; drop also requires SDWIS_DATAFRAME_DROP_ENABLED=true. The Claude Desktop bundle provides the six EPA-reading tools.
The published caller guide covers evidence chains and staged analysis. Plugin, npm, Docker, and MCPB installs include it; MCP clients can read skill://epa-drinking-water-guide/SKILL.md through the Skills extension.
Capability reference
sdwis_list_reference tool
- Required
topic: identifiers, system_types, sources, contaminants, violation_types, enforcement_types, treatment, service_areas, standards, or coverage; optional substring query
- Interpretation topics are bundled; dictionaries are live. SDWIS codes and UCMR analytes remain separate namespaces; standards explain MCL, MCLG, and action levels without classifying historical records as exceedances
sdwis_search_water_systems tool
- At least one narrowing selector:
pwsid, name_contains, state, county (requires state), population_min / population_max, source, system_type, school_or_daycare, primacy_agency, or pwsid_prefix; names match literal case-insensitive substrings and counties match normalized names exactly, including punctuation
activity defaults to active; choose inactive or all explicitly. State means reported legal-entity state; county means county served, and neither establishes complete physical service boundaries
school_or_daycare:true selects reported Y, false selects N; unknown indicators match neither. Discovery and profile summaries preserve school/daycare and wholesaler source indicators; this is not an inventory of every school receiving public water
sdwis_get_system_profile tool
- Required nine-character alphanumeric
pwsid; section is summary (default), geography, service_areas, facilities, or treatment, independently paged
- Summary returns
system with empty items and returned:0; child sections return items. found tracks inventory existence: an empty child section can still return found:true with an empty-section notice. found:false is not a compliance finding; operator contacts and mailing addresses are excluded
- Facilities resolve SDWIS treatment/violation
facility_id values within the same PWSID; optional exact facility_id is valid only for this section. SDWIS and UCMR facility IDs are separate namespaces
sdwis_get_violations tool
- Require
pwsid or state; optional county, contaminant, category, health_based, and compliance_status
- Inclusive
date_from / date_to select compliance-period overlap; a known endpoint can prove overlap despite a missing endpoint. Indeterminate periods are excluded with a count of affected rows examined during the call. Monitoring/reporting violations may lack concentrations
contaminant:lead selects 1030 and Lead and Copper Rule code 5000, including copper and monitoring records; either exact code selects only itself
stage:true requests a partition; stage_max_rows defaults to 5,000 and clamps at 25,000. Supply a held canvas_id to add another partition
sdwis_search_contaminants tool
- Require
dataset (violations, lcr, or ucmr5) and pwsid or state; UCMR also accepts zip alone without SDWIS membership requests. Additional selectors intersect; county still requires state. Optional contaminant and inclusive dates select compliance periods, LCR sample periods, or UCMR collection dates respectively
- LCR lead/copper select PB90/CU90 summaries, not household samples. UCMR
PFAS covers 29 analytes; lithium is separate. zip and detected_only are UCMR-only; service ZIP is not sample location, and censored values remain unknown below reporting limits
- Supports
stage, canvas_id, and stage_max_rows (5,000 default, 25,000 maximum). Cold UCMR calls may need a 180-second client timeout; broad state/county scopes resolve reported-state membership through resumable pages before returning occurrence rows
sdwis_get_enforcement_actions tool
- Actions require
pwsid or reported legal-entity state, with optional state-qualified county; geographic membership resolves before action retrieval. section:associations and any violation_id filter require pwsid; supplied geography still intersects that system
action_type, originator, and inclusive enforcement-date filters apply only to actions. Associations page separately; source action text does not imply a federal order or monetary penalty
- Action types match the complete published code exactly and case-insensitively, including punctuation; missing dictionary labels remain null
- Supports
stage, canvas_id, and stage_max_rows (5,000 default, 25,000 maximum)
sdwis_dataframe_describe tool
- Required caller-held
canvas_id; optional exact table_name; limit defaults to 10 with a cap of 500 and cursor pagination
- One whole table per item includes its
columns array, row count, expiry, filters, provenance, completeness, and continuation; counts and limits are in tables. There is no global canvas listing
- Describe continuations retain their original deadline (at most 15 minutes or table expiry when issued) and reject changed membership, rows, schema, or lineage. Query-driven TTL extensions preserve cursor identity; responses report current authoritative expiry
selection_coverage reports which source partitions and required companion relations remain staged, separately from SQL results
sdwis_dataframe_query tool
- Required
canvas_id and one read-only SELECT, WITH, or FROM statement in sql (at most 20,000 characters); describe the canvas first
- Retains at most
CANVAS_DEFAULT_ROW_LIMIT (framework default 10,000), bounded by 25,000. row_count, truncated, and has_more distinguish retained rows from omitted rows; continuations read an immutable result for up to 15 minutes
- File, network, write, and system-catalog access are refused.
source_complete conservatively includes every live source partition, including tables the SQL does not reference
- Known caller-correctable SQL errors preserve path-redacted diagnostics and structured details; unknown engine/database failures remain generic
selection_coverage.chain_complete confirms the canvas holds a complete source retrieval chain; it does not establish which tables the SQL uses or override truncated. Query continuations retain the original coverage snapshot
sdwis_dataframe_drop tool
- Required
canvas_id and exact table_name; requires CANVAS_PROVIDER_TYPE=duckdb and SDWIS_DATAFRAME_DROP_ENABLED=true
- Idempotent: missing tables or expired canvases return
dropped:false; other staged partitions remain available
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
EPA-specific:
- Keyless SDWIS reads over EPA DMAP, paced at one request per second, with every returned row rechecked against the requested predicates
- Contact-free inventory and service membership; source codes kept alongside EPA dictionary labels, which stay null when EPA has none
- Quarterly SDWIS records, LCR summaries, and limited-participation UCMR 5 monitoring kept distinct; empty results establish neither current compliance, tap-water safety, nor contaminant absence
- UCMR 5 archive downloaded and indexed once per published revision on first use; censored results keep a null value with the reporting limit
Agent-friendly output:
- Provenance —
effective_filters, source URL, retrieved_at, publication/HTTP validators when available, and interpretation caveats; source dates keep their calendar day, with non-midnight timestamps in a _raw field
- Completeness —
source_complete:false can stay false with has_more:false once earlier rows went to other pages or partitions; aggregation does not restore it
- Source coverage —
selection_partition identifies a retrieval and its delivered partition; canvas selection_coverage distinguishes a fully staged chain from missing, dropped, or expired partitions and companions
- Whole-record paging — every
limit defaults to 10 with a cap of 500. Ready pages return the requested number of available whole rows; bounded source work can checkpoint earlier. Follow next_cursor with unchanged selectors, including empty phase:membership pages; source cursors expire after 15 idle minutes
- Lossless output —
content[] and structuredContent carry the same values, including large rows. Byte size does not shorten pages. SDWIS_CLAMP_LIMITS=true clamps over-cap requests with a notice, log entry and counter increment; false advertises and enforces a strict maximum of 500
Getting started
No EPA account or API key is required. Add the server to Claude Code:
For other MCP clients, add this stdio configuration:
For SQL staging, add "CANVAS_PROVIDER_TYPE": "duckdb" to env (Docker: -e CANVAS_PROVIDER_TYPE=duckdb), call a source tool with stage:true, and pass the returned dataset.canvas_id to sdwis_dataframe_describe and sdwis_dataframe_query. The Claude Desktop bundle fixes CANVAS_PROVIDER_TYPE=none; staging there returns canvas_disabled.
For Streamable HTTP, set the transport and start the server:
Prerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- Network access to
data.epa.gov and www.epa.gov; allow a 180-second client timeout for the first UCMR call, which downloads and indexes the archive.
Installation
- Clone the repository:
- Navigate into the directory:
- Install dependencies:
- Configure environment (optional):
Configuration
See .env.example for the full overrides.
Running the server
Local development
Docker
The image defaults to Streamable HTTP on port 3010 with stateless sessions. Add -e CANVAS_PROVIDER_TYPE=duckdb for SQL staging.
Project structure
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catch in tool logic
- Use
ctx.log for logging; source results never go to ctx.state or user storage
- Register new tools in the
createApp() array in src/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields, and never infer compliance, household exposure, or exceedances
Contributing
Issues are welcome. Run checks and tests before submitting:
License
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.