@cyanheads/epa-mcp-server 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +38 -8
  4. package/changelog/0.4.x/0.4.0.md +35 -0
  5. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
  6. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +165 -93
  8. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-facility.tool.js +4 -11
  11. package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +2 -1
  13. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +10 -14
  15. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/index.d.ts +51 -18
  17. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/search-facilities.tool.js +7 -12
  20. package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/search-superfund.tool.js +2 -8
  23. package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +10 -3
  25. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
  26. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +50 -10
  27. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
  29. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +22 -7
  31. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
  33. package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/format-lbs.js +16 -0
  35. package/dist/mcp-server/tools/format-lbs.js.map +1 -0
  36. package/dist/mcp-server/tools/format-location.d.ts +18 -0
  37. package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
  38. package/dist/mcp-server/tools/format-location.js +26 -0
  39. package/dist/mcp-server/tools/format-location.js.map +1 -0
  40. package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
  41. package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
  42. package/dist/mcp-server/tools/format-tri-release.js +23 -0
  43. package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
  44. package/dist/services/airnow/airnow-service.d.ts +10 -29
  45. package/dist/services/airnow/airnow-service.d.ts.map +1 -1
  46. package/dist/services/airnow/airnow-service.js +148 -95
  47. package/dist/services/airnow/airnow-service.js.map +1 -1
  48. package/dist/services/airnow/types.d.ts +62 -23
  49. package/dist/services/airnow/types.d.ts.map +1 -1
  50. package/dist/services/airnow/types.js +2 -1
  51. package/dist/services/airnow/types.js.map +1 -1
  52. package/dist/services/dmap/dmap-service.d.ts +41 -2
  53. package/dist/services/dmap/dmap-service.d.ts.map +1 -1
  54. package/dist/services/dmap/dmap-service.js +231 -116
  55. package/dist/services/dmap/dmap-service.js.map +1 -1
  56. package/dist/services/dmap/types.d.ts +17 -4
  57. package/dist/services/dmap/types.d.ts.map +1 -1
  58. package/dist/services/echo/echo-service.js +1 -1
  59. package/package.json +3 -3
  60. package/server.json +9 -3
package/AGENTS.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** epa-mcp-server
4
- **Version:** 0.3.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.13`
4
+ **Version:** 0.4.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
8
  **Zod:** ^4.6.5
package/CLAUDE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** epa-mcp-server
4
- **Version:** 0.3.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.13`
4
+ **Version:** 0.4.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
8
  **Zod:** ^4.6.5
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.3.3-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/epa-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.2.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/epa-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/epa-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.4.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/epa-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.2.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/epa-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/epa-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -19,11 +19,17 @@
19
19
 
20
20
  </div>
21
21
 
22
+ <div align="center">
23
+
24
+ **Public Hosted Server:** [https://epa.caseyjhand.com/mcp](https://epa.caseyjhand.com/mcp)
25
+
26
+ </div>
27
+
22
28
  ---
23
29
 
24
30
  ## Overview
25
31
 
26
- EPA environmental data across five federal programs — facility compliance (ECHO), toxic chemical releases (TRI), Superfund cleanup sites, drinking water systems (SDWIS), and environmental-justice screening (EJScreen) — plus real-time air quality via AirNow. Search facilities by location or compliance status, pull inspection and enforcement history, track toxic releases across a region, and screen a point for environmental-justice risk from any MCP client. Runs as a stdio process or a local Streamable HTTP server.
32
+ EPA environmental data across five federal programs — facility compliance (ECHO), toxic chemical releases (TRI), Superfund cleanup sites, drinking water systems (SDWIS), and environmental-justice screening (EJScreen) — plus real-time air quality via AirNow. Search facilities by location or compliance status, pull inspection and enforcement history, track toxic releases across a region, and screen a point for environmental-justice risk from any MCP client. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
27
33
 
28
34
  ### Tools
29
35
 
@@ -32,7 +38,7 @@ EPA environmental data across five federal programs — facility compliance (ECH
32
38
  | `epa_search_facilities` | Search EPA-regulated facilities by location, industry program, or compliance status across CAA, CWA, RCRA, TRI, and SDWA |
33
39
  | `epa_get_facility` | Full compliance profile for one facility by EPA Registry ID — inspections, enforcement actions, and penalties |
34
40
  | `epa_search_violations` | Search EPA civil and criminal enforcement cases by state, program, or date range |
35
- | `epa_get_air_quality` | Current AQI observations or next-day forecasts from AirNow |
41
+ | `epa_get_air_quality` | Current AQI observations or the issued AQI forecast from AirNow |
36
42
  | `epa_get_tri_releases` | Per-chemical Toxic Release Inventory data for a single facility |
37
43
  | `epa_search_tri_releases` | Toxic Release Inventory records across facilities in a state or county |
38
44
  | `epa_search_superfund` | Search Superfund (CERCLA/SEMS) sites by location or NPL listing status |
@@ -76,8 +82,10 @@ All resource data is also reachable via tools — use `epa_get_facility` and `ep
76
82
 
77
83
  ### `epa_get_air_quality` <sub>tool</sub>
78
84
 
79
- - Provide `zip_code` or both `latitude` and `longitude`; `mode` is current (default) or forecast (requires ISO `forecast_date`); `distance_miles` defaults to 25, max 300
80
- - Returns per-pollutant AQI, `categoryNumber` (1 Good–6 Hazardous), and `categoryName`; preliminary data is unsuitable for regulatory, trend, or enforcement decisions
85
+ - Provide `zip_code` or both `latitude` and `longitude`; `mode` is current (default) or forecast
86
+ - Current mode returns each pollutant's latest hourly NowCast AQI from the closest monitor within 50 miles, with `siteName`, `siteID`, and `reportingAgency`
87
+ - Forecast mode returns every day the reporting area's agency issued, with `dateValid`, `actionDay`, and the agency's `discussion`; optional `forecast_date` (YYYY-MM-DD) keeps one day and is rejected in current mode. Category-only forecasts carry no `aqi`
88
+ - Readings carry `categoryName` and `categoryNumber` (1 Good–6 Hazardous); `attribution` credits the agencies and the U.S. EPA AirNow program and marks the data preliminary, unsuitable for regulatory, trend, or enforcement decisions
81
89
  - Registered only when `AIRNOW_API_KEY` is set; responses cache for about one hour
82
90
 
83
91
  ---
@@ -86,13 +94,17 @@ All resource data is also reachable via tools — use `epa_get_facility` and `ep
86
94
 
87
95
  - `facility_id` is the TRI `facilityId` from `epa_search_tri_releases`; optional `year` (1987–2030, all available by default) and partial `chemical_name`
88
96
  - Returns per-chemical air, water, land, and underground-injection releases, plus a separate one-time/non-routine total
97
+ - Quantities are in pounds. TRI has facilities report dioxin and dioxin-like compounds in grams; those records are converted to pounds and carry `reportedUnit: "grams"`
89
98
 
90
99
  ---
91
100
 
92
101
  ### `epa_search_tri_releases` <sub>tool</sub>
93
102
 
94
- - Requires 2-letter `state`; optional partial `county`, `chemical_name`, and `year`; up to 200 records (default 50)
103
+ - Requires 2-letter `state`; optional `county` (bare name, case-insensitive partial match, so `Lake` also matches LAKE OF THE WOODS — each record's `countyName` says which; a trailing County, Parish, or Borough is dropped), partial `chemical_name`, and `year`; up to 200 records (default 50)
104
+ - One upstream request however sparse the filters or large the state — EPA joins facilities to their reporting forms server-side
105
+ - Returns each record's one-time/non-routine total; `include_release_breakdown: true` adds air, water, land, and underground-injection releases at the cost of one more upstream request (up to ~20 seconds when EPA has not cached it)
95
106
  - Returns `facilityId` for `epa_get_tri_releases`; enrichment marks results truncated when the limit is reached
107
+ - Dioxin and dioxin-like compounds, reported to TRI in grams, are converted to pounds and carry `reportedUnit: "grams"`
96
108
 
97
109
  ---
98
110
 
@@ -105,8 +117,9 @@ All resource data is also reachable via tools — use `epa_get_facility` and `ep
105
117
 
106
118
  ### `epa_search_water_systems` <sub>tool</sub>
107
119
 
108
- - Requires `state` or `zip_code`; up to 200 systems (default 50)
109
- - `has_violation` filters active violations; `pws_type` is community, non-transient, or transient, returned as CWS / NTNCWS / TNCWS in `type`
120
+ - Requires `state` or `zip_code`; up to 200 systems (default 50). `zip_code` matches each system's address of record (often the owner's or operator's office), not its service area
121
+ - `has_violation` keeps systems with an open SDWIS violation; violations returned to compliance or closed out when the system was deactivated don't count. `pws_type` is community, non-transient, or transient, returned as CWS / NTNCWS / TNCWS in `type`
122
+ - With `state` alone, `has_violation` reads the state's first 1,000 open violation records; with `zip_code`, it reads up to 1,000 systems in the ZIP and checks them by PWSID. When either read fills, an enrichment `notice` says the list may miss violating systems
110
123
 
111
124
  ---
112
125
 
@@ -149,6 +162,23 @@ Agent-friendly output:
149
162
 
150
163
  ## Getting started
151
164
 
165
+ ### Public Hosted Instance
166
+
167
+ A public instance is available at `https://epa.caseyjhand.com/mcp` — no installation required. It runs without an AirNow key, so `epa_get_air_quality` is not available there; the other 8 tools are. Point any MCP client at it via Streamable HTTP:
168
+
169
+ ```json
170
+ {
171
+ "mcpServers": {
172
+ "epa-mcp-server": {
173
+ "type": "streamable-http",
174
+ "url": "https://epa.caseyjhand.com/mcp"
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### Self-Hosted / Local
181
+
152
182
  Add the following to your MCP client configuration file. An AirNow API key is optional — set `AIRNOW_API_KEY` to enable `epa_get_air_quality` (register free at [docs.airnowapi.org](https://docs.airnowapi.org/account/request/)); without it the server starts with the other 8 tools. ECHO, DMAP, and EJScreen tools work without authentication.
153
183
 
154
184
  ```json
@@ -0,0 +1,35 @@
1
+ ---
2
+ summary: "epa_get_air_quality moves to AirNow's 2026 web services (breaking: distance_miles removed, observation coordinates removed, aqi optional); TRI search runs as one DMAP join with a working county filter and opt-in release breakdown; water-system has_violation counts open violations; hosted endpoint at epa.caseyjhand.com."
3
+ breaking: true
4
+ security: false
5
+ agent-notes: |
6
+ Callers of epa_get_air_quality: drop `distance_miles`, stop reading `latitude`/`longitude`, treat `hourObserved` as a string and `aqi` as optional.
7
+ ---
8
+
9
+ # 0.4.0 — 2026-10-08
10
+
11
+ ## Added
12
+
13
+ - **`epa_get_air_quality`** reads `observation/current/ziplatlong/` and `forecast/current/`, returns per-site readings (`siteName`, `siteID`, `reportingAgency`), the agency `discussion`, and an `attribution` credits block ([#30](https://github.com/cyanheads/epa-mcp-server/issues/30)).
14
+ - **`epa_search_tri_releases`** takes opt-in `include_release_breakdown` for air, water, land, and underground-injection releases at the cost of one more upstream request ([#17](https://github.com/cyanheads/epa-mcp-server/issues/17)).
15
+ - **`epa_search_tri_releases`** records carry `countyName`; TRI quantities reported in grams (dioxin and dioxin-like compounds) are converted to pounds and carry `reportedUnit`.
16
+ - **Hosted endpoint** `https://epa.caseyjhand.com/mcp` is listed in `server.json` remotes and the README.
17
+
18
+ ## Changed
19
+
20
+ - **`epa_get_air_quality` (breaking)** removes the `distance_miles` input and the `latitude`/`longitude` output fields, retypes `hourObserved`, and makes `aqi` optional (category-only forecasts omit it). `forecast_date` is optional, filters the issued forecast, and is rejected in current mode.
21
+ - **`epa_search_tri_releases`** is one DMAP join instead of an unbounded facility walk ([#32](https://github.com/cyanheads/epa-mcp-server/issues/32)); the `county` filter matches `county_name` after dropping a trailing County, Parish, or Borough ([#27](https://github.com/cyanheads/epa-mcp-server/issues/27)).
22
+ - **`epa_search_water_systems`** `has_violation` keeps systems with an open violation, in chunked lookups with a scan notice, and works with `zip_code`; `zip_code` is documented as the address of record.
23
+ - **`epa_get_facility`**, **`epa_search_facilities`**, **`epa_search_superfund`** render latitude and longitude independently ([#24](https://github.com/cyanheads/epa-mcp-server/issues/24)) and pound quantities at full precision.
24
+ - **`facility_id`** guidance on `epa_get_tri_releases` points at `epa_search_tri_releases` ([#21](https://github.com/cyanheads/epa-mcp-server/issues/21)).
25
+ - **Tests** assert exact tool sets, error messages, and per-field output across the suite.
26
+
27
+ ## Fixed
28
+
29
+ - **DMAP row ranges** are 1-based and inclusive.
30
+ - **`epa_search_tri_releases`** under-limit searches no longer fail during enrichment.
31
+
32
+ ## Dependencies
33
+
34
+ - `@cyanheads/mcp-ts-core` ^0.13.13 → ^0.13.14 (framework skills synced).
35
+ - `pino-pretty` ^13.1.3 → ^13.2.0.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * @fileoverview Tool for getting AQI observations or forecasts for a location from AirNow.
2
+ * @fileoverview Tool for getting current AQI observations or the issued AQI forecast for a
3
+ * location from AirNow.
3
4
  * @module mcp-server/tools/definitions/get-air-quality.tool
4
5
  */
5
6
  import { z } from '@cyanheads/mcp-ts-core';
@@ -13,34 +14,47 @@ export declare const getAirQualityTool: import("@cyanheads/mcp-ts-core").ToolDef
13
14
  forecast: "forecast";
14
15
  }>>;
15
16
  forecast_date: z.ZodOptional<z.ZodString>;
16
- distance_miles: z.ZodDefault<z.ZodNumber>;
17
17
  }, z.core.$strip>, z.ZodObject<{
18
18
  observations: z.ZodArray<z.ZodObject<{
19
19
  reportingArea: z.ZodOptional<z.ZodString>;
20
+ reportingAreaCode: z.ZodOptional<z.ZodString>;
20
21
  stateCode: z.ZodOptional<z.ZodString>;
21
- latitude: z.ZodOptional<z.ZodNumber>;
22
- longitude: z.ZodOptional<z.ZodNumber>;
23
22
  dateObserved: z.ZodOptional<z.ZodString>;
24
- hourObserved: z.ZodOptional<z.ZodNumber>;
23
+ hourObserved: z.ZodOptional<z.ZodString>;
25
24
  localTimeZone: z.ZodOptional<z.ZodString>;
25
+ discussion: z.ZodOptional<z.ZodString>;
26
26
  readings: z.ZodArray<z.ZodObject<{
27
27
  parameterName: z.ZodString;
28
- aqi: z.ZodNumber;
28
+ aqi: z.ZodOptional<z.ZodNumber>;
29
29
  categoryNumber: z.ZodOptional<z.ZodNumber>;
30
30
  categoryName: z.ZodOptional<z.ZodString>;
31
+ siteName: z.ZodOptional<z.ZodString>;
32
+ siteID: z.ZodOptional<z.ZodString>;
33
+ reportingAgency: z.ZodOptional<z.ZodString>;
34
+ dateIssue: z.ZodOptional<z.ZodString>;
35
+ dateValid: z.ZodOptional<z.ZodString>;
36
+ forecastAgency: z.ZodOptional<z.ZodString>;
37
+ actionDay: z.ZodOptional<z.ZodBoolean>;
31
38
  }, z.core.$strip>>;
32
39
  }, z.core.$strip>>;
33
40
  mode: z.ZodString;
34
41
  message: z.ZodOptional<z.ZodString>;
42
+ attribution: z.ZodString;
35
43
  }, z.core.$strip>, readonly [{
36
44
  readonly reason: "no_location";
37
45
  readonly code: JsonRpcErrorCode.ValidationError;
38
46
  readonly when: "Neither zip_code nor latitude+longitude was provided.";
39
47
  readonly recovery: "Provide either zip_code or both latitude and longitude to identify the location.";
40
48
  }, {
41
- readonly reason: "forecast_date_required";
49
+ readonly reason: "forecast_date_needs_forecast_mode";
42
50
  readonly code: JsonRpcErrorCode.ValidationError;
43
- readonly when: "Mode is \"forecast\" but forecast_date was not provided.";
44
- readonly recovery: "Provide forecast_date as YYYY-MM-DD when using mode=\"forecast\".";
51
+ readonly when: "forecast_date was set while mode is \"current\", where it has no effect.";
52
+ readonly recovery: "Set mode to \"forecast\" to filter by forecast_date, or omit forecast_date.";
53
+ }, {
54
+ readonly reason: "invalid_zip_code";
55
+ readonly code: JsonRpcErrorCode.ValidationError;
56
+ readonly when: "AirNow rejected zip_code as invalid (HTTP 400).";
57
+ readonly recovery: "Check that zip_code is a valid 5-digit US ZIP code, or pass latitude and longitude instead.";
58
+ readonly thrownBy: "service";
45
59
  }], undefined>;
46
60
  //# sourceMappingURL=get-air-quality.tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"get-air-quality.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-air-quality.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAIjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAmO5B,CAAC"}
1
+ {"version":3,"file":"get-air-quality.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-air-quality.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA+BjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA0R5B,CAAC"}
@@ -1,16 +1,35 @@
1
1
  /**
2
- * @fileoverview Tool for getting AQI observations or forecasts for a location from AirNow.
2
+ * @fileoverview Tool for getting current AQI observations or the issued AQI forecast for a
3
+ * location from AirNow.
3
4
  * @module mcp-server/tools/definitions/get-air-quality.tool
4
5
  */
5
6
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
7
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
8
  import { getAirNowService } from '../../../services/airnow/airnow-service.js';
9
+ const AIRNOW_PROGRAM = 'the U.S. EPA AirNow program (www.airnow.gov)';
10
+ const PRELIMINARY_NOTICE = 'AirNow data are preliminary and unverified; do not use them for regulatory, trend, or enforcement decisions.';
11
+ /** Credit line naming the agencies behind the returned readings and the AirNow program. */
12
+ function attributionFor(mode, areas) {
13
+ const agencies = [
14
+ ...new Set(areas.flatMap((area) => area.readings.flatMap((r) => {
15
+ const agency = r.reportingAgency ?? r.forecastAgency;
16
+ return agency ? [agency] : [];
17
+ }))),
18
+ ];
19
+ const list = (items) => new Intl.ListFormat('en', { type: 'conjunction' }).format(items);
20
+ const credit = mode === 'current'
21
+ ? `Data courtesy of ${list([...agencies, AIRNOW_PROGRAM])}.`
22
+ : agencies.length > 0
23
+ ? `Forecasts issued by ${list(agencies)} and distributed by ${AIRNOW_PROGRAM}.`
24
+ : `Forecasts distributed by ${AIRNOW_PROGRAM}.`;
25
+ return `${credit} ${PRELIMINARY_NOTICE}`;
26
+ }
8
27
  export const getAirQualityTool = tool('epa_get_air_quality', {
9
28
  title: 'Get Air Quality Index',
10
- description: 'Get current AQI observations or forecasts for a location from the AirNow API. Returns per-pollutant AQI values (PM2.5, ozone, CO, SO2, NO2), AQI category (Good through Hazardous), reporting area name, and observation timestamp. Provide either zip_code or both latitude and longitude. Set mode to "forecast" and provide forecast_date for next-day projections. Data is preliminary — suitable for awareness and informational use, not regulatory decisions. Responses are cached for ~1 hour.',
29
+ description: "Get current AQI observations or the issued AQI forecast for a US location from the AirNow API. Provide either zip_code or both latitude and longitude. Current mode returns the latest hourly NowCast AQI for each pollutant (PM2.5, ozone, PM10) from the closest monitor within 50 miles, with the monitor site name, site ID, and reporting agency; pollutants in one reporting area can come from different monitors. Forecast mode returns every day the reporting area's agency has issued (typically one to five), each with the AQI category, an action-day flag, and the issue date, plus the agency's forecast discussion when one exists; some agencies forecast a category without an AQI number. In forecast mode, set forecast_date to keep only the forecast valid on that date; current mode rejects it. Data are preliminary — suitable for awareness, not regulatory, trend, or enforcement decisions. Responses are cached for ~1 hour.",
11
30
  annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
12
31
  input: z.object({
13
- zip_code: z.string().optional().describe('5-digit ZIP code for the location'),
32
+ zip_code: z.string().optional().describe('5-digit US ZIP code for the location'),
14
33
  latitude: z
15
34
  .number()
16
35
  .optional()
@@ -22,18 +41,11 @@ export const getAirQualityTool = tool('epa_get_air_quality', {
22
41
  mode: z
23
42
  .enum(['current', 'forecast'])
24
43
  .default('current')
25
- .describe('Data mode: "current" for latest AQI observations, "forecast" for next-day projections'),
44
+ .describe('Data mode: "current" for the latest hourly observations, "forecast" for every forecast day the reporting area has issued'),
26
45
  forecast_date: z
27
46
  .string()
28
47
  .optional()
29
- .describe('Date for forecast in ISO 8601 format (YYYY-MM-DD). Required when mode is "forecast".'),
30
- distance_miles: z
31
- .number()
32
- .int()
33
- .min(0)
34
- .max(300)
35
- .default(25)
36
- .describe('Search radius in miles for finding reporting stations. Default 25 miles.'),
48
+ .describe('Forecast mode only (rejected in current mode): keep just the forecast valid on this date (YYYY-MM-DD). Omit to get every issued forecast day. When no issued day matches, the result is empty and its message lists the issued dates.'),
37
49
  }),
38
50
  output: z.object({
39
51
  observations: z
@@ -42,48 +54,91 @@ export const getAirQualityTool = tool('epa_get_air_quality', {
42
54
  reportingArea: z
43
55
  .string()
44
56
  .optional()
45
- .describe('Name of the AQI reporting area (e.g. "Seattle-Tacoma-Bellevue, WA")'),
46
- stateCode: z.string().optional().describe('2-letter state code of the reporting area'),
47
- latitude: z.number().optional().describe('Latitude of the reporting area centroid'),
48
- longitude: z.number().optional().describe('Longitude of the reporting area centroid'),
57
+ .describe('Name of the AQI reporting area (e.g. "Seattle-Bellevue-Kent Valley")'),
58
+ reportingAreaCode: z
59
+ .string()
60
+ .optional()
61
+ .describe('AirNow reporting area code (e.g. "wa004"). Forecast mode only.'),
62
+ stateCode: z
63
+ .string()
64
+ .optional()
65
+ .describe('2-letter state code of the reporting area. Forecast mode only.'),
49
66
  dateObserved: z
50
67
  .string()
51
68
  .optional()
52
- .describe('Observation or forecast date (YYYY-MM-DD)'),
69
+ .describe('Observation date (YYYY-MM-DD). Current mode only.'),
53
70
  hourObserved: z
54
- .number()
71
+ .string()
55
72
  .optional()
56
- .describe('Hour of observation (0–23) in local time. Absent for daily forecasts.'),
73
+ .describe('Local hour of the observation as "HH:00". Current mode only.'),
57
74
  localTimeZone: z
58
75
  .string()
59
76
  .optional()
60
- .describe('Local time zone abbreviation (e.g. "PST")'),
77
+ .describe('Local time zone abbreviation for hourObserved (e.g. "PDT"). Current mode only.'),
78
+ discussion: z
79
+ .string()
80
+ .optional()
81
+ .describe("The forecasting agency's discussion for this area, verbatim (may contain HTML). Forecast mode only; omitted when the agency issued none."),
61
82
  readings: z
62
83
  .array(z
63
84
  .object({
64
85
  parameterName: z
65
86
  .string()
66
- .describe('Pollutant name (e.g. "PM2.5", "Ozone", "CO")'),
67
- aqi: z.number().describe('Air Quality Index value for this pollutant'),
87
+ .describe('Pollutant name as AirNow reports it (e.g. "PM2.5", "OZONE", "PM10")'),
88
+ aqi: z
89
+ .number()
90
+ .optional()
91
+ .describe('Air Quality Index value. Omitted when the agency issued only a category (category-only forecast).'),
68
92
  categoryNumber: z
69
93
  .number()
70
94
  .optional()
71
- .describe('AQI category number: 1=Good, 2=Moderate, 3=Unhealthy for Sensitive Groups, 4=Unhealthy, 5=Very Unhealthy, 6=Hazardous'),
95
+ .describe('AQI category number: 1=Good, 2=Moderate, 3=Unhealthy for Sensitive Groups, 4=Unhealthy, 5=Very Unhealthy, 6=Hazardous. Omitted when AirNow reports an unrecognized category.'),
72
96
  categoryName: z
73
97
  .string()
74
98
  .optional()
75
99
  .describe('AQI category name (e.g. "Good", "Moderate", "Unhealthy")'),
100
+ siteName: z
101
+ .string()
102
+ .optional()
103
+ .describe('Monitor site that produced this observation. Current mode only.'),
104
+ siteID: z
105
+ .string()
106
+ .optional()
107
+ .describe('AirNow ID of the monitor site. Current mode only.'),
108
+ reportingAgency: z
109
+ .string()
110
+ .optional()
111
+ .describe('Agency that reported this observation. Current mode only.'),
112
+ dateIssue: z
113
+ .string()
114
+ .optional()
115
+ .describe('Date the forecast was issued (YYYY-MM-DD). Forecast mode only.'),
116
+ dateValid: z
117
+ .string()
118
+ .optional()
119
+ .describe('Date the forecast applies to (YYYY-MM-DD). Forecast mode only.'),
120
+ forecastAgency: z
121
+ .string()
122
+ .optional()
123
+ .describe('Agency that issued the forecast. Forecast mode only.'),
124
+ actionDay: z
125
+ .boolean()
126
+ .optional()
127
+ .describe('True when the agency declared an air quality action day for this date. Forecast mode only.'),
76
128
  })
77
- .describe('AQI reading for a single pollutant parameter'))
78
- .describe('Per-pollutant AQI readings for this reporting area and time'),
129
+ .describe('AQI reading for one pollutant (and, in forecast mode, one valid date)'))
130
+ .describe('Per-pollutant AQI readings for this reporting area'),
79
131
  })
80
- .describe('AQI observation or forecast record for a single reporting area'))
132
+ .describe('AQI readings for one reporting area — per observed hour in current mode, per area across all forecast days in forecast mode'))
81
133
  .describe('AQI observation or forecast records grouped by reporting area'),
82
134
  mode: z.string().describe('Data mode used: "current" or "forecast"'),
83
135
  message: z
84
136
  .string()
85
137
  .optional()
86
- .describe('Recovery hint when no observations are returned — suggests trying a different location or increasing distance_miles. Absent when data is returned.'),
138
+ .describe("Why the result is empty: AirNow's own explanation, the issued forecast dates when forecast_date matched none, or a coverage hint. Absent when data is returned."),
139
+ attribution: z
140
+ .string()
141
+ .describe('Credit to the reporting or forecasting agencies and the U.S. EPA AirNow program, with the preliminary-data notice. Reproduce it when passing this data on.'),
87
142
  }),
88
143
  errors: [
89
144
  {
@@ -93,98 +148,115 @@ export const getAirQualityTool = tool('epa_get_air_quality', {
93
148
  recovery: 'Provide either zip_code or both latitude and longitude to identify the location.',
94
149
  },
95
150
  {
96
- reason: 'forecast_date_required',
151
+ reason: 'forecast_date_needs_forecast_mode',
152
+ code: JsonRpcErrorCode.ValidationError,
153
+ when: 'forecast_date was set while mode is "current", where it has no effect.',
154
+ recovery: 'Set mode to "forecast" to filter by forecast_date, or omit forecast_date.',
155
+ },
156
+ {
157
+ reason: 'invalid_zip_code',
97
158
  code: JsonRpcErrorCode.ValidationError,
98
- when: 'Mode is "forecast" but forecast_date was not provided.',
99
- recovery: 'Provide forecast_date as YYYY-MM-DD when using mode="forecast".',
159
+ when: 'AirNow rejected zip_code as invalid (HTTP 400).',
160
+ recovery: 'Check that zip_code is a valid 5-digit US ZIP code, or pass latitude and longitude instead.',
161
+ thrownBy: 'service',
100
162
  },
101
163
  ],
102
164
  async handler(input, ctx) {
103
- // Resolve the location once into a discriminated shape so the service branches below
104
- // narrow without non-null assertions. Zip takes precedence over lat/lng, and the raw
105
- // (untrimmed) zip_code is forwarded to match prior behavior.
106
- const zip = input.zip_code;
107
- const loc = zip !== undefined && zip.trim() !== ''
165
+ // Zip takes precedence over lat/lng; a blank zip (form clients) falls through to lat/lng.
166
+ const zip = input.zip_code?.trim();
167
+ const location = zip
108
168
  ? { kind: 'zip', zipCode: zip }
109
169
  : input.latitude !== undefined && input.longitude !== undefined
110
170
  ? { kind: 'latlng', latitude: input.latitude, longitude: input.longitude }
111
171
  : undefined;
112
- if (!loc) {
172
+ if (!location) {
113
173
  throw ctx.fail('no_location', 'Provide either zip_code or both latitude and longitude.');
114
174
  }
115
- // forecast mode requires a date — validate up front so the error path is unchanged.
116
- let forecastDate = '';
117
- if (input.mode === 'forecast') {
118
- if (input.forecast_date === undefined || input.forecast_date.trim() === '') {
119
- throw ctx.fail('forecast_date_required', 'forecast_date (YYYY-MM-DD) is required when mode is "forecast".');
120
- }
121
- forecastDate = input.forecast_date;
175
+ const forecastDate = input.forecast_date?.trim() || undefined;
176
+ if (forecastDate && input.mode === 'current') {
177
+ throw ctx.fail('forecast_date_needs_forecast_mode', `forecast_date "${forecastDate}" only filters forecasts; current mode returns the latest observations.`);
122
178
  }
123
179
  ctx.log.info('epa_get_air_quality', {
124
180
  mode: input.mode,
125
- zip: input.zip_code,
126
- lat: input.latitude,
181
+ location: location.kind,
182
+ forecastDate,
127
183
  });
128
184
  const service = getAirNowService();
129
- let observations;
130
- if (input.mode === 'current') {
131
- observations =
132
- loc.kind === 'zip'
133
- ? await service.getCurrentByZip({ zipCode: loc.zipCode, distanceMiles: input.distance_miles }, ctx)
134
- : await service.getCurrentByLatLng({
135
- latitude: loc.latitude,
136
- longitude: loc.longitude,
137
- distanceMiles: input.distance_miles,
138
- }, ctx);
185
+ const lookup = input.mode === 'forecast'
186
+ ? await service.getForecast(location, ctx)
187
+ : await service.getCurrent(location, ctx);
188
+ const observations = input.mode === 'forecast' && forecastDate
189
+ ? lookup.areas.flatMap((area) => {
190
+ const readings = area.readings.filter((r) => r.dateValid === forecastDate);
191
+ return readings.length > 0 ? [{ ...area, readings }] : [];
192
+ })
193
+ : lookup.areas;
194
+ ctx.log.info('epa_get_air_quality completed', { areas: observations.length });
195
+ const attribution = attributionFor(input.mode, observations);
196
+ if (observations.length > 0)
197
+ return { observations, mode: input.mode, attribution };
198
+ const where = location.kind === 'zip'
199
+ ? `zip_code="${location.zipCode}"`
200
+ : `latitude=${location.latitude}, longitude=${location.longitude}`;
201
+ let message;
202
+ if (lookup.noDataMessage !== undefined) {
203
+ message = `AirNow returned no data for ${where}: "${lookup.noDataMessage}"`;
139
204
  }
140
- else {
141
- observations =
142
- loc.kind === 'zip'
143
- ? await service.getForecastByZip({ zipCode: loc.zipCode, date: forecastDate, distanceMiles: input.distance_miles }, ctx)
144
- : await service.getForecastByLatLng({
145
- latitude: loc.latitude,
146
- longitude: loc.longitude,
147
- date: forecastDate,
148
- distanceMiles: input.distance_miles,
149
- }, ctx);
205
+ else if (lookup.areas.length > 0) {
206
+ const issued = [
207
+ ...new Set(lookup.areas.flatMap((a) => a.readings.flatMap((r) => r.dateValid ?? []))),
208
+ ].sort();
209
+ message = `No forecast valid on ${forecastDate} for ${where}. ${issued.length > 0 ? `Issued forecast dates: ${issued.join(', ')}.` : 'AirNow listed no valid dates.'} Omit forecast_date to get every issued day.`;
150
210
  }
151
- ctx.log.info('epa_get_air_quality completed', { areas: observations.length });
152
- if (observations.length === 0) {
153
- const location = loc.kind === 'zip'
154
- ? `zip_code="${input.zip_code}"`
155
- : `lat=${input.latitude}, lng=${input.longitude}`;
156
- return {
157
- observations: [],
158
- mode: input.mode,
159
- message: `No AQI data found for ${location} within ${input.distance_miles} miles. Try increasing distance_miles or check that the location is within the US.`,
160
- };
211
+ else {
212
+ message = `No AQI data found for ${where}. AirNow observations come from monitors within 50 miles and forecasts from the location's reporting area; check that the location is in the US, or try a nearby ZIP code.`;
161
213
  }
162
- return { observations, mode: input.mode };
214
+ return { observations: [], mode: input.mode, message, attribution };
163
215
  },
164
216
  format: (result) => {
165
- const lines = [];
166
- lines.push(`## Air Quality Index — ${result.mode === 'forecast' ? 'Forecast' : 'Current Observations'} (mode: ${result.mode})`);
217
+ const lines = [
218
+ `## Air Quality Index — ${result.mode === 'forecast' ? 'Forecast' : 'Current Observations'} (mode: ${result.mode})`,
219
+ ];
167
220
  if (result.message)
168
221
  lines.push(`\n> ${result.message}`);
169
- for (const obs of result.observations) {
170
- lines.push(`\n### ${obs.reportingArea ?? 'Unknown Area'}`);
171
- if (obs.stateCode)
172
- lines.push(`**State:** ${obs.stateCode}`);
173
- if (obs.dateObserved) {
174
- const timeStr = obs.hourObserved !== undefined
175
- ? ` at ${obs.hourObserved}:00 ${obs.localTimeZone ?? ''}`
222
+ for (const area of result.observations) {
223
+ lines.push(`\n### ${area.reportingArea ?? 'Unknown Area'}`);
224
+ const meta = [
225
+ area.stateCode && `**State:** ${area.stateCode}`,
226
+ area.reportingAreaCode && `**Reporting area code:** ${area.reportingAreaCode}`,
227
+ ].filter(Boolean);
228
+ if (meta.length > 0)
229
+ lines.push(meta.join(' | '));
230
+ if (area.dateObserved || area.hourObserved) {
231
+ const time = area.hourObserved
232
+ ? ` at ${area.hourObserved}${area.localTimeZone ? ` ${area.localTimeZone}` : ''}`
176
233
  : '';
177
- lines.push(`**Date:** ${obs.dateObserved}${timeStr}`);
234
+ lines.push(`**Observed:** ${area.dateObserved ?? 'date not reported'}${time}`);
178
235
  }
179
- if (obs.latitude !== undefined && obs.longitude !== undefined) {
180
- lines.push(`**Coordinates:** ${obs.latitude}, ${obs.longitude}`);
181
- }
182
- for (const reading of obs.readings) {
183
- const category = reading.categoryName ? ` (${reading.categoryName})` : '';
184
- const catNum = reading.categoryNumber !== undefined ? ` [category ${reading.categoryNumber}]` : '';
185
- lines.push(`**${reading.parameterName}:** AQI ${reading.aqi}${category}${catNum}`);
236
+ for (const r of area.readings) {
237
+ const label = r.dateValid ? `${r.dateValid} ${r.parameterName}` : r.parameterName;
238
+ const value = r.aqi !== undefined
239
+ ? `AQI ${r.aqi}${r.categoryName ? ` (${r.categoryName})` : ''}`
240
+ : (r.categoryName ?? 'Category not reported');
241
+ const category = r.categoryNumber !== undefined ? ` [category ${r.categoryNumber}]` : '';
242
+ const categoryOnly = r.aqi === undefined ? ' (category only, no AQI number)' : '';
243
+ const site = r.siteName || r.siteID
244
+ ? ` — ${[r.siteName, r.siteID && `site ${r.siteID}`].filter(Boolean).join(', ')}`
245
+ : '';
246
+ const details = [
247
+ r.reportingAgency,
248
+ (r.dateIssue || r.forecastAgency) &&
249
+ ['issued', r.dateIssue, r.forecastAgency && `by ${r.forecastAgency}`]
250
+ .filter(Boolean)
251
+ .join(' '),
252
+ r.actionDay !== undefined && `action day: ${r.actionDay ? 'yes' : 'no'}`,
253
+ ].filter(Boolean);
254
+ lines.push(`- **${label}:** ${value}${category}${categoryOnly}${site}${details.length > 0 ? ` · ${details.join(' · ')}` : ''}`);
186
255
  }
256
+ if (area.discussion)
257
+ lines.push(`\n**Discussion:**\n${area.discussion}`);
187
258
  }
259
+ lines.push(`\n**Source:** ${result.attribution}`);
188
260
  return [{ type: 'text', text: lines.join('\n') }];
189
261
  },
190
262
  });