@cyanheads/epa-drinking-water-mcp-server
v0.3.0
Published
Search EPA drinking water systems, violations, enforcement actions, and contaminant measurements via MCP. STDIO or Streamable HTTP.
Maintainers
Readme
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
| Tool | Description |
|:---|:---|
| sdwis_list_reference | Decode identifiers, contaminant namespaces, coverage, standards, and EPA dictionaries |
| sdwis_search_water_systems | Find contact-free public water-system inventory |
| sdwis_get_system_profile | Retrieve summary, geography, service areas, facilities, or treatment |
| sdwis_get_violations | Page reported historical SDWA violations |
| sdwis_search_contaminants | Select violations, LCR summaries, or UCMR 5 occurrence |
| sdwis_get_enforcement_actions | Retrieve actions or violation associations |
| sdwis_dataframe_describe | Inspect tables and provenance inside a supplied canvas |
| sdwis_dataframe_query | Run read-only SQL against staged partitions |
| sdwis_dataframe_drop | Optionally remove one staged table |
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, orcoverage; optional substringquery - 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, orpwsid_prefix; names match literal case-insensitive substrings and counties match normalized names exactly, including punctuation activitydefaults toactive; chooseinactiveorallexplicitly. State means reported legal-entity state; county means county served, and neither establishes complete physical service boundariesschool_or_daycare:trueselects reportedY, false selectsN; 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;sectionissummary(default),geography,service_areas,facilities, ortreatment, independently paged - Summary returns
systemwith emptyitemsandreturned:0; child sections returnitems.foundtracks inventory existence: an empty child section can still returnfound:truewith an empty-section notice.found:falseis not a compliance finding; operator contacts and mailing addresses are excluded - Facilities resolve SDWIS treatment/violation
facility_idvalues within the same PWSID; optional exactfacility_idis valid only for this section. SDWIS and UCMR facility IDs are separate namespaces
sdwis_get_violations tool
- Require
pwsidorstate; optionalcounty,contaminant,category,health_based, andcompliance_status - Inclusive
date_from/date_toselect 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:leadselects1030and Lead and Copper Rule code5000, including copper and monitoring records; either exact code selects only itselfstage:truerequests a partition;stage_max_rowsdefaults to 5,000 and clamps at 25,000. Supply a heldcanvas_idto add another partition
sdwis_search_contaminants tool
- Require
dataset(violations,lcr, orucmr5) andpwsidorstate; UCMR also acceptszipalone 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
PFAScovers 29 analytes; lithium is separate.zipanddetected_onlyare UCMR-only; service ZIP is not sample location, and censored values remain unknown below reporting limits - Supports
stage,canvas_id, andstage_max_rows(5,000 default, 25,000 maximum). Cold UCMR calls may need a 180-second client timeout; broadstate/countyscopes resolve reported-state membership through resumable pages before returning occurrence rows
sdwis_get_enforcement_actions tool
- Actions require
pwsidor reported legal-entitystate, with optional state-qualifiedcounty; geographic membership resolves before action retrieval.section:associationsand anyviolation_idfilter requirepwsid; 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, andstage_max_rows(5,000 default, 25,000 maximum)
sdwis_dataframe_describe tool
- Required caller-held
canvas_id; optional exacttable_name;limitdefaults to 10 with a cap of 500 andcursorpagination - One whole table per item includes its
columnsarray, 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_coveragereports which source partitions and required companion relations remain staged, separately from SQL results
sdwis_dataframe_query tool
- Required
canvas_idand one read-only SELECT, WITH, or FROM statement insql(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, andhas_moredistinguish 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_completeconservatively 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_completeconfirms the canvas holds a complete source retrieval chain; it does not establish which tables the SQL uses or overridetruncated. Query continuations retain the original coverage snapshot
sdwis_dataframe_drop tool
- Required
canvas_idand exacttable_name; requiresCANVAS_PROVIDER_TYPE=duckdbandSDWIS_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_rawfield - Completeness —
source_complete:falsecan stay false withhas_more:falseonce earlier rows went to other pages or partitions; aggregation does not restore it - Source coverage —
selection_partitionidentifies a retrieval and its delivered partition; canvasselection_coveragedistinguishes a fully staged chain from missing, dropped, or expired partitions and companions - Whole-record paging — every
limitdefaults to 10 with a cap of 500. Ready pages return the requested number of available whole rows; bounded source work can checkpoint earlier. Follownext_cursorwith unchanged selectors, including emptyphase:membershippages; source cursors expire after 15 idle minutes - Lossless output —
content[]andstructuredContentcarry the same values, including large rows. Byte size does not shorten pages.SDWIS_CLAMP_LIMITS=trueclamps 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:
claude mcp add epa-drinking-water-mcp-server -- npx -y @cyanheads/epa-drinking-water-mcp-serverFor other MCP clients, add this stdio configuration:
{
"mcpServers": {
"epa-drinking-water-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/epa-drinking-water-mcp-server"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio"
}
}
}
}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:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcpPrerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- Network access to
data.epa.govandwww.epa.gov; allow a 180-second client timeout for the first UCMR call, which downloads and indexes the archive.
Installation
- Clone the repository:
git clone https://github.com/cyanheads/epa-drinking-water-mcp-server.git- Navigate into the directory:
cd epa-drinking-water-mcp-server- Install dependencies:
bun install- Configure environment (optional):
cp .env.example .env
# set CANVAS_PROVIDER_TYPE=duckdb to enable SQL stagingConfiguration
| Variable | Description | Default |
|:---|:---|:---|
| SDWIS_CLAMP_LIMITS | Clamp limit above 500 with a notice; false advertises and enforces a strict maximum | true |
| SDWIS_DATAFRAME_DROP_ENABLED | Enable early removal of staged tables when canvas is enabled | false |
| CANVAS_PROVIDER_TYPE | none or duckdb; npm/Docker supports DuckDB, MCPB fixes none | none |
| CANVAS_DEFAULT_ROW_LIMIT | SQL row ceiling, bounded by server maximum 25,000 | 10000 |
| CANVAS_DEFAULT_MEMORY_LIMIT_MB | Per-engine budget, at most 512 MiB | 512 |
| MCP_TRANSPORT_TYPE | stdio or http | stdio |
| MCP_HTTP_HOST | HTTP bind address; Docker binds 0.0.0.0 | 127.0.0.1 |
| MCP_HTTP_PORT | HTTP port | 3010 |
| MCP_AUTH_MODE | none, jwt, or oauth | none |
| MCP_LOG_LEVEL | Minimum log level | info |
| OTEL_ENABLED | Enable OpenTelemetry | false |
See .env.example for the full overrides.
Running the server
Local development
bun run rebuild
bun run start:stdio
# or bun run start:http
bun run devcheck
bun run testDocker
docker build -t epa-drinking-water-mcp-server .
docker run --rm -p 3010:3010 epa-drinking-water-mcp-serverThe image defaults to Streamable HTTP on port 3010 with stateless sessions. Add -e CANVAS_PROVIDER_TYPE=duckdb for SQL staging.
Project structure
| Path | Purpose |
|:---|:---|
| src/index.ts | Registration, source initialization, and optional canvas |
| src/config | Paging clamp and dataframe-drop configuration |
| src/domain | Selectors, reference data, schemas, and dual-surface formatting |
| src/mcp-server/tools/definitions | Six EPA tools and three optional dataframe tools |
| src/services/sdwis | Validated DMAP reads, membership, paging, and joins |
| src/services/ucmr5 | Archive acquisition, parsing, indexing, and occurrence |
| src/services/dataframe | Capability-scoped staging, SQL, expiry, and removal |
| tests | Domain, service, tool, and integration tests |
| scripts | Build, checks, packaging, and release tooling |
| skills/epa-drinking-water-guide | Published caller workflow guide, also served over MCP |
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catchin tool logic - Use
ctx.logfor logging; source results never go toctx.stateor user storage - Register new tools in the
createApp()array insrc/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:
bun run devcheck
bun run testLicense
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.
