@cyanheads/epa-mcp-server 0.4.0 → 0.4.1

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 +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/Dockerfile +3 -3
  4. package/README.md +24 -19
  5. package/changelog/0.4.x/0.4.1.md +34 -0
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
  9. package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
  10. package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
  11. package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
  12. package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
  13. package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
  15. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-facility.tool.js +208 -43
  17. package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +4 -2
  19. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +12 -5
  21. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/index.d.ts +116 -16
  23. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/search-facilities.tool.js +34 -16
  26. package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
  28. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-superfund.tool.js +11 -1
  30. package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +7 -0
  32. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +21 -9
  34. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
  36. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
  38. package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
  39. package/dist/services/airnow/airnow-service.d.ts.map +1 -1
  40. package/dist/services/airnow/airnow-service.js +9 -3
  41. package/dist/services/airnow/airnow-service.js.map +1 -1
  42. package/dist/services/call-budget.d.ts +22 -0
  43. package/dist/services/call-budget.d.ts.map +1 -0
  44. package/dist/services/call-budget.js +28 -0
  45. package/dist/services/call-budget.js.map +1 -0
  46. package/dist/services/dmap/dmap-service.d.ts +43 -13
  47. package/dist/services/dmap/dmap-service.d.ts.map +1 -1
  48. package/dist/services/dmap/dmap-service.js +270 -68
  49. package/dist/services/dmap/dmap-service.js.map +1 -1
  50. package/dist/services/echo/echo-service.d.ts +38 -11
  51. package/dist/services/echo/echo-service.d.ts.map +1 -1
  52. package/dist/services/echo/echo-service.js +417 -223
  53. package/dist/services/echo/echo-service.js.map +1 -1
  54. package/dist/services/echo/types.d.ts +167 -80
  55. package/dist/services/echo/types.d.ts.map +1 -1
  56. package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
  57. package/dist/services/ejscreen/ejscreen-service.js +9 -3
  58. package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
  59. package/package.json +6 -6
  60. package/server.json +3 -3
@@ -4,52 +4,190 @@
4
4
  * and enforcement case search.
5
5
  * @module services/echo/echo-service
6
6
  */
7
- import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
7
+ import { JsonRpcErrorCode, McpError, notFound, serviceUnavailable, } from '@cyanheads/mcp-ts-core/errors';
8
8
  import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
9
  import { getServerConfig } from '../../config/server-config.js';
10
- /** Normalizes a raw ECHO facility to the domain type. */
10
+ import { callBudget } from '../call-budget.js';
11
+ /**
12
+ * Budget for one getFacility call — every get_dfr attempt plus the status lookup — kept under
13
+ * the 60 s default request timeout of MCP SDK clients.
14
+ */
15
+ const FACILITY_DEADLINE_MS = 50_000;
16
+ /** Ceiling for the best-effort p_frs status lookup within that budget. */
17
+ const STATUS_LOOKUP_DEADLINE_MS = 10_000;
18
+ /**
19
+ * Budget for one searchFacilities or searchViolations call — the discovery request, its get_qid
20
+ * page, and every retry and backoff of either — kept under the 60 s default request timeout of
21
+ * MCP SDK clients. A legitimate `p_ct=Los Angeles` facility search takes 54.6–56.7 s, so the
22
+ * budget keeps it and leaves 2 s for transport.
23
+ */
24
+ const SEARCH_DEADLINE_MS = 58_000;
25
+ /**
26
+ * The facility-search columns normalizeFacility maps, as echo_rest_services.metadata column IDs:
27
+ * FacName, FacStreet, FacCity, FacState, FacZip, RegistryID, FacCounty, FacLat, FacLong, TRIIDs,
28
+ * FacComplianceStatus, FacInspectionCount, FacTotalPenalties, TRIReleasesTransfers, FacFIPSCode,
29
+ * FacDerivedStctyFIPS, AIRFlag, NPDESFlag, SDWISFlag, RCRAFlag. ECHO's default row omits the
30
+ * coordinates, the per-program flags, and the inspection and penalty counts.
31
+ */
32
+ const FACILITY_SEARCH_COLUMNS = '1,2,3,4,5,6,7,19,20,28,38,43,62,70,83,93,100,101,102,103';
33
+ /** ECHO `p_med` media code per program; `p_med` takes a single code. */
34
+ const PROGRAM_MEDIA_CODES = {
35
+ CAA: 'A',
36
+ CWA: 'W',
37
+ RCRA: 'R',
38
+ TRI: 'T',
39
+ SDWA: 'S',
40
+ };
41
+ /** `p_cs` value selecting facilities with one or more programs in significant noncompliance. */
42
+ const SIGNIFICANT_NONCOMPLIANCE = '3';
43
+ /** ECHO `p_civil_criminal_indicator` per case type; `all` sends none. */
44
+ const CASE_TYPE_CODES = { civil: 'CI', criminal: 'CR', all: undefined };
45
+ /**
46
+ * Case milestones whose date is the filing date ECHO reports as DateFiled. ECHO records them for
47
+ * civil cases only, so a date-bounded case search returns no criminal case.
48
+ */
49
+ const FILING_MILESTONES = 'CMF,CPOA';
50
+ /** Open start for a filing-date range given only an end; ECHO requires both ends. */
51
+ const EARLIEST_FILING_DATE = '1900-01-01';
52
+ /** ECHO's MM/DD/YYYY form of a YYYY-MM-DD date. */
53
+ const toEchoDate = (iso) => `${iso.slice(5, 7)}/${iso.slice(8, 10)}/${iso.slice(0, 4)}`;
54
+ /**
55
+ * ECHO runs a search without any parameter it does not recognize and leaves that parameter out
56
+ * of `Results.QueryParameters`. Fails when a sent `p_*` filter is missing there by name. Values
57
+ * are not compared: ECHO also lists defaults it was not sent and echoes values it ignores.
58
+ */
59
+ function assertFiltersApplied(endpoint, url, applied) {
60
+ const names = new Set((applied ?? []).map((parameter) => parameter.QP));
61
+ const dropped = [...new URL(url).searchParams.keys()].filter((key) => key.startsWith('p_') && !names.has(key));
62
+ if (dropped.length === 0)
63
+ return;
64
+ const filters = dropped.length === 1 ? 'that filter' : 'those filters';
65
+ throw new McpError(JsonRpcErrorCode.InternalError, `ECHO ${endpoint} did not apply ${dropped.join(', ')}, so its results would ignore ${filters}. Retrying will not help; search without ${filters}.`, { droppedParameters: dropped, retryable: false });
66
+ }
67
+ /**
68
+ * One SEARCH_DEADLINE_MS budget for an ECHO search. Each request the search makes takes
69
+ * `remainingMs()` as its deadline, so the discovery request and its get_qid page share the budget.
70
+ */
71
+ const searchBudget = (search, narrow) => callBudget('ECHO', search, SEARCH_DEADLINE_MS, narrow);
72
+ /** Disclosure for a profile built without the p_frs row. */
73
+ const STATUS_LOOKUP_NOTICE = "ECHO's facility search request failed, so this profile has no complianceStatus or compliance.quartersInViolation. Every other field comes from ECHO's detailed facility report; calling again may fill them.";
74
+ /**
75
+ * Newest compliance monitoring records kept in a profile. A Texas refinery logs several hundred
76
+ * state investigations in five years (581 at registry 110041990913, about 130 KB unbounded).
77
+ */
78
+ const INSPECTION_CAP = 100;
79
+ /** Drops keys whose value is undefined, so optional output fields are absent rather than undefined. */
80
+ function compact(record) {
81
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
82
+ }
83
+ /** ECHO reports missing values as null or ""; both read as absent. */
84
+ const echoText = (value) => value || undefined;
85
+ /** Parses an ECHO count or dollar string ("12", "$1,750", "75,223"). */
86
+ function parseEchoNumber(value) {
87
+ if (!value)
88
+ return;
89
+ const parsed = Number(value.replace(/[$,]/g, ''));
90
+ return Number.isNaN(parsed) ? undefined : parsed;
91
+ }
92
+ /** Sort key (YYYYMMDD) for an ECHO MM/DD/YYYY date. */
93
+ const dateKey = (date) => date ? `${date.slice(6)}${date.slice(0, 2)}${date.slice(3, 5)}` : '';
94
+ /**
95
+ * Classifies ECHO's in-band failure: HTTP 200 with `Results.Error.ErrorMessage`. Only an
96
+ * unrecognized message stays a retryable ServiceUnavailable. The queryset-limit message
97
+ * ("Rows Returned would be <n>…") comes from get_facilities, which this service never calls;
98
+ * get_facility_info and get_case_info have no row ceiling.
99
+ */
100
+ function echoBodyError(message) {
101
+ const detail = `ECHO API error: ${message}`;
102
+ if (/^ID .+ is invalid\.$/s.test(message))
103
+ return notFound(detail);
104
+ if (/^QueryID .+ not found in ECHO\.$/s.test(message)) {
105
+ return serviceUnavailable(detail, { retryable: false });
106
+ }
107
+ return serviceUnavailable(detail);
108
+ }
109
+ /** The NotFound `epa_get_facility` and `epa://facility/{registry_id}` declare as `facility_not_found`. */
110
+ function facilityNotFound(registryId, cause) {
111
+ return notFound(`No facility found for Registry ID "${registryId}".`, { reason: 'facility_not_found', registryId }, cause === undefined ? undefined : { cause });
112
+ }
113
+ /** Settlement dates recorded on an enforcement case. */
114
+ const settlementDates = (entry) => (entry.Settlements?.Settlement ?? []).flatMap((s) => s.SettlementDate ? [s.SettlementDate] : []);
115
+ /** A case's issue date; ECHO writes an unknown one as "01/01/0001". */
116
+ const issueDate = (entry) => entry.IssueDate && entry.IssueDate !== '01/01/0001' ? entry.IssueDate : undefined;
117
+ /** Federal plus state and local penalty across a case's settlements, in dollars. */
118
+ const settledPenalty = (entry) => (entry.Settlements?.Settlement ?? []).reduce((sum, s) => sum + (parseEchoNumber(s.FederalPenalty) ?? 0) + (parseEchoNumber(s.StateLocalPenalty) ?? 0), 0);
119
+ /**
120
+ * Removes and returns the `CaseFormalActions` entry that records `action`: same statute, same
121
+ * source (the case prefixes it, e.g. "AIR/"), and issued — or, lacking an issue date, settled —
122
+ * on the action date. Among several such cases, the one whose settled penalty equals the
123
+ * action's penalty wins.
124
+ */
125
+ function takeCase(cases, action) {
126
+ const matches = cases.flatMap((entry, index) => {
127
+ const issued = issueDate(entry);
128
+ const sameAction = entry.StatuteCode === action.Statute &&
129
+ entry.SourceID?.split('/').pop() === action.SourceID &&
130
+ (issued
131
+ ? issued === action.ActionDate
132
+ : settlementDates(entry).includes(action.ActionDate ?? ''));
133
+ return sameAction ? [{ entry, index }] : [];
134
+ });
135
+ const penalty = parseEchoNumber(action.PenaltyAmount) ?? 0;
136
+ const match = matches.find(({ entry }) => settledPenalty(entry) === penalty) ?? matches[0];
137
+ return match && cases.splice(match.index, 1)[0];
138
+ }
139
+ /** The latest TRIHistory year and its releases + transfers, summed across rows for that year. */
140
+ function latestTriTotal(rows) {
141
+ const year = Math.max(...rows.map((row) => Number(row.Year)).filter(Number.isFinite));
142
+ const totals = rows
143
+ .filter((row) => Number(row.Year) === year)
144
+ .map((row) => parseEchoNumber(row.TotalReleasesTransfers))
145
+ .filter((lbs) => lbs !== undefined);
146
+ return totals.length > 0 ? { year, lbs: totals.reduce((sum, lbs) => sum + lbs, 0) } : undefined;
147
+ }
148
+ /** The reporting year in a TRI permit's FacilityStatus ("Last Reported for 2024"); 0 when absent. */
149
+ const lastReportedYear = (status) => Number(/\b(\d{4})\b/.exec(status ?? '')?.[1] ?? 0);
150
+ /**
151
+ * Normalizes an ECHO facility row, read by the column names FACILITY_SEARCH_COLUMNS requests, to
152
+ * the domain type. Program flags match the `p_med` filters: a TRI facility is one with a TRI ID
153
+ * (ECHO's TRIFlag marks current reporters only). getFacility passes the same columns, some unset.
154
+ */
11
155
  function normalizeFacility(raw) {
12
- const penaltyAir = parseFloat(raw.AIRPenalties ?? '0') || 0;
13
- const penaltyCwa = parseFloat(raw.CWAPenalties ?? '0') || 0;
14
- const penaltyRcr = parseFloat(raw.RCRPenalties ?? '0') || 0;
15
- const totalPenalties = penaltyAir + penaltyCwa + penaltyRcr;
16
156
  return {
17
157
  registryId: raw.RegistryID ?? '',
18
158
  name: raw.FacName ?? '',
19
- ...(raw.FacStreet && { street: raw.FacStreet }),
20
- ...(raw.FacCity && { city: raw.FacCity }),
21
- ...(raw.FacState && { state: raw.FacState }),
22
- ...(raw.FacZip && { zip: raw.FacZip }),
23
- ...(raw.FacCounty && { county: raw.FacCounty }),
24
- ...(raw.FacFIPSCode && { fipsCode: raw.FacFIPSCode }),
25
- ...(raw.FacLat &&
26
- !Number.isNaN(parseFloat(raw.FacLat)) && { latitude: parseFloat(raw.FacLat) }),
27
- ...(raw.FacLon &&
28
- !Number.isNaN(parseFloat(raw.FacLon)) && { longitude: parseFloat(raw.FacLon) }),
29
- ...(raw.FacComplianceStatus && { complianceStatus: raw.FacComplianceStatus }),
159
+ ...compact({
160
+ street: echoText(raw.FacStreet),
161
+ city: echoText(raw.FacCity),
162
+ state: echoText(raw.FacState),
163
+ zip: echoText(raw.FacZip),
164
+ county: echoText(raw.FacCounty),
165
+ fipsCode: echoText(raw.FacFIPSCode) ?? echoText(raw.FacDerivedStctyFIPS),
166
+ latitude: parseEchoNumber(raw.FacLat),
167
+ longitude: parseEchoNumber(raw.FacLong),
168
+ complianceStatus: echoText(raw.FacComplianceStatus),
169
+ }),
30
170
  programs: {
31
171
  air: raw.AIRFlag === 'Y',
32
- water: raw.CWAFlag === 'Y',
33
- rcra: raw.RCRFlag === 'Y',
34
- tri: raw.TRIFlag === 'Y',
35
- sdwa: raw.SDWAFlag === 'Y',
172
+ water: raw.NPDESFlag === 'Y',
173
+ rcra: raw.RCRAFlag === 'Y',
174
+ tri: echoText(raw.TRIIDs) !== undefined,
175
+ sdwa: raw.SDWISFlag === 'Y',
36
176
  },
37
- ...(raw.TRIReleasesTransfers &&
38
- !Number.isNaN(parseFloat(raw.TRIReleasesTransfers)) && {
39
- triReleasesTransfersInLbs: parseFloat(raw.TRIReleasesTransfers),
40
- }),
41
- ...(raw.InspectionCount &&
42
- !Number.isNaN(parseInt(raw.InspectionCount, 10)) && {
43
- inspectionCount: parseInt(raw.InspectionCount, 10),
177
+ ...compact({
178
+ triReleasesTransfersInLbs: parseEchoNumber(raw.TRIReleasesTransfers),
179
+ inspectionCount: parseEchoNumber(raw.FacInspectionCount),
180
+ totalPenaltiesInDollars: parseEchoNumber(raw.FacTotalPenalties),
44
181
  }),
45
- ...(totalPenalties > 0 && { totalPenaltiesInDollars: totalPenalties }),
46
182
  };
47
183
  }
48
184
  export class EchoService {
49
185
  baseUrl;
50
- // config and storage retained for future caching/state use
51
- constructor(_config, _storage) {
186
+ userAgent;
187
+ // storage retained for future caching/state use
188
+ constructor(config, _storage) {
52
189
  this.baseUrl = getServerConfig().echoBaseUrl;
190
+ this.userAgent = `${config.pkg.name}/${config.pkg.version}`;
53
191
  }
54
192
  /** Build URL with query params. */
55
193
  buildUrl(path, params) {
@@ -61,13 +199,16 @@ export class EchoService {
61
199
  }
62
200
  return url.toString();
63
201
  }
64
- /** Fetch JSON from ECHO with retry. */
65
- // biome-ignore lint/suspicious/useAwait: delegates to withRetry() which returns a Promise — async typing is correct
66
- async fetchJson(url, ctx) {
67
- return withRetry(async () => {
202
+ /**
203
+ * Fetch JSON from ECHO with retry. `retry.deadlineMs` bounds every attempt and backoff and is
204
+ * required, so no ECHO request runs without a share of its tool call's budget; `maxRetries`
205
+ * narrows the attempt count.
206
+ */
207
+ fetchJson(url, ctx, retry) {
208
+ return withRetry(async ({ signal }) => {
68
209
  const response = await fetch(url, {
69
- signal: ctx.signal,
70
- headers: { 'User-Agent': '@cyanheads/epa-mcp-server/0.4.0' },
210
+ signal,
211
+ headers: { 'User-Agent': this.userAgent },
71
212
  });
72
213
  if (!response.ok) {
73
214
  throw await httpErrorFromResponse(response, { service: 'ECHO' });
@@ -77,62 +218,46 @@ export class EchoService {
77
218
  throw serviceUnavailable('ECHO API returned HTML instead of JSON — likely rate-limited or unavailable.');
78
219
  }
79
220
  const parsed = JSON.parse(text);
80
- // ECHO returns bot-blocking errors in Results.Error.ErrorMessage rather than HTTP error codes
81
221
  const errorMsg = parsed.Results
82
222
  ?.Error?.ErrorMessage;
83
- if (errorMsg) {
84
- throw serviceUnavailable(`ECHO API error: ${errorMsg}`);
85
- }
223
+ if (errorMsg)
224
+ throw echoBodyError(errorMsg);
86
225
  return parsed;
87
226
  }, {
88
227
  operation: 'EchoService.fetchJson',
89
228
  baseDelayMs: 1000,
90
229
  signal: ctx.signal,
230
+ ...retry,
91
231
  });
92
232
  }
93
233
  /**
94
- * Search EPA-regulated facilities using ECHO get_facility_info.
95
- * At least one geographic parameter must be supplied.
234
+ * Search EPA-regulated facilities using ECHO get_facility_info, then get_qid for the first
235
+ * page when ECHO does not return the rows inline. At least one geographic parameter must be
236
+ * supplied.
96
237
  */
97
238
  async searchFacilities(params, ctx) {
239
+ const budget = searchBudget('facility search', 'Narrow it with zip_code instead of city or state, a smaller radius_miles, or a programs filter, or call again later.');
98
240
  const limit = params.limit ?? 50;
99
- const qparams = {
241
+ // buildUrl drops undefined values; latitude/longitude of 0 are valid and sent.
242
+ const url = this.buildUrl('echo_rest_services.get_facility_info', {
100
243
  output: 'JSON',
101
244
  responseset: limit,
102
- };
103
- if (params.zipCode)
104
- qparams.p_zip = params.zipCode;
105
- if (params.state)
106
- qparams.p_state = params.state;
107
- if (params.city)
108
- qparams.p_city = params.city;
109
- // Proximity search — latitude/longitude of 0 are valid, so guard with !== undefined.
110
- if (params.latitude !== undefined)
111
- qparams.p_lat = params.latitude;
112
- if (params.longitude !== undefined)
113
- qparams.p_long = params.longitude;
114
- if (params.radiusMiles !== undefined)
115
- qparams.p_radius = params.radiusMiles;
116
- if (params.activeOnly)
117
- qparams.p_act = 'Y';
118
- if (params.hasViolation)
119
- qparams.p_sv_flag = 'Y';
120
- // Program filters — comma-separated list restricts to facilities in those programs
121
- if (params.programs?.length) {
122
- const programMap = {
123
- CAA: 'AIR',
124
- CWA: 'CWA',
125
- RCRA: 'RCRA',
126
- TRI: 'TRI',
127
- SDWA: 'SDWA',
128
- };
129
- const mapped = params.programs.map((p) => programMap[p]).filter(Boolean);
130
- if (mapped.length)
131
- qparams.p_naa = mapped.join(',');
132
- }
133
- const url = this.buildUrl('echo_rest_services.get_facility_info', qparams);
245
+ qcolumns: FACILITY_SEARCH_COLUMNS,
246
+ p_zip: params.zipCode,
247
+ p_st: params.state,
248
+ p_ct: params.city,
249
+ p_lat: params.latitude,
250
+ p_long: params.longitude,
251
+ p_radius: params.radiusMiles,
252
+ p_act: params.activeOnly ? 'Y' : undefined,
253
+ p_cs: params.hasViolation ? SIGNIFICANT_NONCOMPLIANCE : undefined,
254
+ p_med: params.program && PROGRAM_MEDIA_CODES[params.program],
255
+ });
134
256
  ctx.log.debug('ECHO facility search', { endpoint: 'echo_rest_services.get_facility_info' });
135
- const data = await this.fetchJson(url, ctx);
257
+ const data = await this.fetchJson(url, ctx, {
258
+ deadlineMs: budget.remainingMs(),
259
+ }).catch(budget.rethrow);
260
+ assertFiltersApplied('echo_rest_services.get_facility_info', url, data.Results?.QueryParameters);
136
261
  const directFacilities = data.Results?.Facilities ?? [];
137
262
  const parsedTotalCount = Number.parseInt(data.Results?.QueryRows ?? data.Results?.TotalCount ?? '', 10);
138
263
  const totalCount = Number.isNaN(parsedTotalCount) ? directFacilities.length : parsedTotalCount;
@@ -148,150 +273,205 @@ export class EchoService {
148
273
  output: 'JSON',
149
274
  qid: queryId,
150
275
  pageno: 1,
276
+ qcolumns: FACILITY_SEARCH_COLUMNS,
151
277
  });
152
278
  ctx.log.debug('ECHO facility search page', {
153
279
  endpoint: 'echo_rest_services.get_qid',
154
280
  queryId,
155
281
  });
156
- const page = await this.fetchJson(qidUrl, ctx);
282
+ const page = await this.fetchJson(qidUrl, ctx, {
283
+ deadlineMs: budget.remainingMs(),
284
+ }).catch(budget.rethrow);
157
285
  raw = page.Results?.Facilities ?? [];
158
286
  }
159
- return {
160
- facilities: raw
161
- .slice(0, limit)
162
- .map((f) => normalizeFacility(f)),
163
- totalCount,
164
- };
287
+ return { facilities: raw.slice(0, limit).map(normalizeFacility), totalCount };
165
288
  }
166
289
  /**
167
- * Retrieve a full compliance profile for a single facility, aggregating multiple DFR endpoints.
290
+ * Retrieve a full compliance profile for a single facility from one
291
+ * dfr_rest_services.get_dfr response, plus a best-effort facility search row for the
292
+ * facility-level compliance status that no DFR section carries.
168
293
  *
169
- * Step 1 uses dfr_rest_services.get_dfr (accepts Registry IDs directly) to obtain base facility
170
- * data. The previous approach used echo_rest_services.get_facility_info?p_id=registryId, which
171
- * only accepts program-specific IDs (e.g. RCRA handler IDs), never numeric Registry IDs — so it
172
- * always returned 0 results. Steps 2-5 run in parallel after the initial DFR fetch.
294
+ * get_dfr takes a Registry ID or a program ID (a TRI or RCRA ID resolves to its Registry ID);
295
+ * echo_rest_services.get_facility_info takes the resolved Registry ID only as `p_frs`.
296
+ * A failed status lookup is disclosed as an enrichment notice.
173
297
  */
174
298
  async getFacility(registryId, ctx) {
175
- // Step 1: get_dfr accepts Registry IDs via p_id and returns facility name/address in
176
- // Results.Permits[] (EPASystem="FRS" record) and confirms identity via Results.RegistryID.
177
- const dfrUrl = this.buildUrl('dfr_rest_services.get_dfr', {
178
- output: 'JSON',
179
- p_id: registryId,
180
- });
299
+ const { profile, notice } = await this.getFacilityReport(registryId, ctx);
300
+ if (notice)
301
+ ctx.enrich.notice(notice);
302
+ return profile;
303
+ }
304
+ /** The getFacility profile, with the notice to show when the status lookup failed. */
305
+ async getFacilityReport(registryId, ctx) {
306
+ const startedAt = Date.now();
307
+ const dfrUrl = this.buildUrl('dfr_rest_services.get_dfr', { output: 'JSON', p_id: registryId });
181
308
  ctx.log.debug('ECHO get_dfr', { registryId });
182
- const dfrData = await this.fetchJson(dfrUrl, ctx);
183
- const confirmedId = dfrData.Results?.RegistryID;
184
- if (!confirmedId) {
185
- throw serviceUnavailable(`ECHO returned no facility for Registry ID "${registryId}". Check the ID and try again.`, { registryId });
186
- }
187
- // Extract base facility info from the FRS permit record in Permits[]
188
- const permits = dfrData.Results?.Permits ?? [];
189
- const frsPerm = permits.find((p) => p.EPASystem === 'FRS');
190
- // Derive program flags from the statutes present in the Permits list
191
- const statutes = new Set(permits.map((p) => p.Statute?.toUpperCase()).filter(Boolean));
192
- const raw = {
193
- RegistryID: confirmedId,
194
- FacName: frsPerm?.FacilityName ?? undefined,
195
- FacStreet: frsPerm?.FacilityStreet ?? undefined,
196
- FacCity: frsPerm?.FacilityCity ?? undefined,
197
- FacState: frsPerm?.FacilityState ?? undefined,
198
- FacZip: frsPerm?.FacilityZip ?? undefined,
199
- FacCounty: frsPerm?.FacilityCountyName ?? undefined,
200
- FacFIPSCode: frsPerm?.FacilityFipsCode ?? undefined,
201
- FacLat: frsPerm?.Latitude ?? dfrData.Results?.SpatialMetadata?.Latitude83 ?? undefined,
202
- FacLon: frsPerm?.Longitude ?? dfrData.Results?.SpatialMetadata?.Longitude83 ?? undefined,
203
- // Program flags derived from Permits[].Statute (CAA→AIR, CWA, RCRA, TRI, SDWA)
204
- AIRFlag: statutes.has('CAA') ? 'Y' : 'N',
205
- CWAFlag: statutes.has('CWA') || statutes.has('NPDES') ? 'Y' : 'N',
206
- RCRFlag: statutes.has('RCRA') ? 'Y' : 'N',
207
- TRIFlag: statutes.has('TRI') ? 'Y' : 'N',
208
- SDWAFlag: statutes.has('SDWA') ? 'Y' : 'N',
309
+ const dfr = await this.fetchJson(dfrUrl, ctx, {
310
+ deadlineMs: FACILITY_DEADLINE_MS,
311
+ }).catch((error) => {
312
+ throw error instanceof McpError && error.code === JsonRpcErrorCode.NotFound
313
+ ? facilityNotFound(registryId, error)
314
+ : error;
315
+ });
316
+ const results = dfr.Results;
317
+ const confirmedId = results?.RegistryID;
318
+ if (!results || !confirmedId)
319
+ throw facilityNotFound(registryId);
320
+ const permits = results.Permits ?? [];
321
+ const frs = permits.find((p) => p.EPASystem === 'FRS');
322
+ const systemPermits = (system) => permits.filter((p) => p.EPASystem === system);
323
+ const soleSourceId = (system) => {
324
+ const ids = systemPermits(system).flatMap((p) => (p.SourceID ? [p.SourceID] : []));
325
+ return ids.length === 1 ? ids[0] : undefined;
209
326
  };
210
- const base = normalizeFacility(raw);
211
- // Steps 2-5: Parallel DFR calls
212
- const [complianceResult, inspEnfResult, airResult, waterResult] = await Promise.allSettled([
213
- this.fetchJson(this.buildUrl('dfr_rest_services.get_compliance_summary', {
214
- output: 'JSON',
215
- p_id: registryId,
216
- }), ctx),
217
- this.fetchJson(this.buildUrl('dfr_rest_services.get_inspection_enforcement', {
218
- output: 'JSON',
219
- p_id: registryId,
220
- }), ctx),
221
- base.programs.air
222
- ? this.fetchJson(this.buildUrl('dfr_rest_services.get_air', { output: 'JSON', p_id: registryId }), ctx)
223
- : Promise.resolve(null),
224
- base.programs.water
225
- ? this.fetchJson(this.buildUrl('dfr_rest_services.get_water', { output: 'JSON', p_id: registryId }), ctx)
226
- : Promise.resolve(null),
227
- ]);
228
- // Extract compliance summary
229
- let compliance;
230
- if (complianceResult.status === 'fulfilled') {
231
- const cs = complianceResult.value?.Results?.ComplianceSummary;
232
- if (cs) {
233
- compliance = {
234
- ...(cs.MediaStatusCode && { mediaStatusCode: cs.MediaStatusCode }),
235
- ...(cs.MediaStatusDesc && { mediaStatusDescription: cs.MediaStatusDesc }),
236
- ...(cs.QtrsInViol !== undefined && { quartersInViolation: Number(cs.QtrsInViol) }),
237
- };
238
- }
239
- }
240
- // Extract inspections and formal actions
241
- const inspections = [];
242
- const formalActions = [];
243
- if (inspEnfResult.status === 'fulfilled') {
244
- const ie = inspEnfResult.value?.Results;
245
- for (const insp of ie?.Inspections ?? []) {
246
- inspections.push({
247
- ...(insp.ActivityType && { activityType: insp.ActivityType }),
248
- ...(insp.ActivityDate && { activityDate: insp.ActivityDate }),
249
- ...(insp.ActivityTypeDescription && { description: insp.ActivityTypeDescription }),
250
- });
251
- }
252
- for (const fa of ie?.FormalActions ?? []) {
253
- formalActions.push({
254
- ...(fa.CaseID && { caseId: fa.CaseID }),
255
- ...(fa.SettlementDate && { settlementDate: fa.SettlementDate }),
256
- ...(fa.PenaltyAssessed !== undefined && {
257
- penaltyAssessedInDollars: Number(fa.PenaltyAssessed),
258
- }),
259
- ...(fa.FormalActionType && { actionType: fa.FormalActionType }),
260
- });
261
- }
262
- }
263
- // Extract air compliance
264
- let airCompliance;
265
- if (airResult.status === 'fulfilled' && airResult.value) {
266
- const ac = airResult.value?.Results?.AirComplianceSummary;
267
- if (ac) {
268
- airCompliance = {
269
- ...(ac.ProgramID && { programId: ac.ProgramID }),
270
- ...(ac.Status && { status: ac.Status }),
271
- ...(ac.StatusDate && { statusDate: ac.StatusDate }),
272
- };
273
- }
274
- }
275
- // Extract water compliance
276
- let waterCompliance;
277
- if (waterResult.status === 'fulfilled' && waterResult.value) {
278
- const wc = waterResult.value?.Results?.WaterCompliance;
279
- if (wc) {
280
- waterCompliance = {
281
- ...(wc.ProgramID && { programId: wc.ProgramID }),
282
- ...(wc.Status && { status: wc.Status }),
283
- ...(wc.PermitID && { permitId: wc.PermitID }),
284
- };
285
- }
286
- }
287
- return {
327
+ const flag = (system) => (systemPermits(system).length > 0 ? 'Y' : 'N');
328
+ // The FRS entry carries null county and FIPS; program entries carry "King County" / "53033".
329
+ const permitValue = (key) => [frs, ...permits].map((p) => echoText(p?.[key])).find((value) => value !== undefined);
330
+ const status = await this.fetchFacilityStatusRow(confirmedId, ctx, Math.max(0, FACILITY_DEADLINE_MS - (Date.now() - startedAt)));
331
+ const statusRow = status.row;
332
+ const triFacilityIds = [
333
+ ...new Set(systemPermits('TRI')
334
+ .sort((a, b) => lastReportedYear(b.FacilityStatus) - lastReportedYear(a.FacilityStatus))
335
+ .flatMap((p) => (p.SourceID ? [p.SourceID] : []))),
336
+ ];
337
+ // Program flags key on ECHO's media systems: an EIS, CEDRI, RMP, or GHGRP entry also carries
338
+ // Statute CAA without an ICIS-Air record, and a TRI entry carries Statute EP313. County and
339
+ // FIPS come from Permits[] first, so they do not depend on the best-effort status row.
340
+ const base = normalizeFacility({
341
+ RegistryID: confirmedId,
342
+ FacName: frs?.FacilityName,
343
+ FacStreet: frs?.FacilityStreet,
344
+ FacCity: frs?.FacilityCity,
345
+ FacState: frs?.FacilityState,
346
+ FacZip: frs?.FacilityZip,
347
+ FacCounty: permitValue('FacilityCountyName') ?? statusRow?.FacCounty,
348
+ FacFIPSCode: permitValue('FacilityFipsCode') ?? statusRow?.FacFIPSCode,
349
+ FacDerivedStctyFIPS: statusRow?.FacDerivedStctyFIPS,
350
+ FacLat: frs?.Latitude ?? results.SpatialMetadata?.Latitude83,
351
+ FacLong: frs?.Longitude ?? results.SpatialMetadata?.Longitude83,
352
+ FacComplianceStatus: statusRow?.FacComplianceStatus,
353
+ AIRFlag: flag('ICIS-Air'),
354
+ NPDESFlag: flag('ICIS-NPDES'),
355
+ RCRAFlag: flag('RCRAInfo'),
356
+ TRIIDs: triFacilityIds.join(','),
357
+ SDWISFlag: flag('SDWIS'),
358
+ });
359
+ const tri = latestTriTotal((results.TRIHistory?.Sources ?? []).flatMap((source) => source.ReleaseYear ?? []));
360
+ const programDates = new Map((results.EnforcementComplianceSummaries?.ProgramDates ?? []).map((d) => [d.Program, d]));
361
+ const programCompliance = (results.EnforcementComplianceSummaries?.Summaries ?? []).flatMap((summary) => {
362
+ const recorded = compact({
363
+ currentStatus: echoText(summary.CurrentStatus),
364
+ quartersInNoncompliance: parseEchoNumber(summary.QtrsInNC),
365
+ quartersInSignificantNoncompliance: parseEchoNumber(summary.QtrsInSNC),
366
+ inspections: parseEchoNumber(summary.Inspections),
367
+ lastInspectionDate: echoText(summary.LastInspection),
368
+ informalActions: parseEchoNumber(summary.InformalActions),
369
+ formalActions: parseEchoNumber(summary.FormalActions),
370
+ totalPenaltiesInDollars: parseEchoNumber(summary.TotalPenalties),
371
+ });
372
+ // ECHO lists a statute row with no values for a program the facility has no record in.
373
+ if (!summary.Statute || Object.keys(recorded).length === 0)
374
+ return [];
375
+ const dates = programDates.get(summary.Statute);
376
+ return [
377
+ compact({
378
+ statute: summary.Statute,
379
+ ...recorded,
380
+ threeYearPeriodStart: echoText(dates?.StartDate3Yr),
381
+ fiveYearPeriodStart: echoText(dates?.StartDate5Yr),
382
+ periodEnd: echoText(dates?.EndDate),
383
+ }),
384
+ ];
385
+ });
386
+ const programSummary = (statute) => programCompliance.find((p) => p.statute === statute);
387
+ const monitoring = results.ComplianceHistory?.Inspection ?? [];
388
+ const inspections = [...monitoring]
389
+ .sort((a, b) => dateKey(b.Date).localeCompare(dateKey(a.Date)))
390
+ .slice(0, INSPECTION_CAP)
391
+ .map((inspection) => compact({
392
+ category: echoText(inspection.ActivityType),
393
+ activityType: echoText(inspection.InspectionType),
394
+ activityDate: echoText(inspection.Date),
395
+ description: echoText(inspection.Finding),
396
+ statute: echoText(inspection.Statute),
397
+ sourceId: echoText(inspection.SourceID),
398
+ leadAgency: echoText(inspection.LeadAgency),
399
+ }));
400
+ const cases = [...(results.CaseFormalActions?.Action ?? [])];
401
+ const formalActions = [...(results.FormalActions?.Action ?? [])]
402
+ .sort((a, b) => dateKey(b.ActionDate).localeCompare(dateKey(a.ActionDate)))
403
+ .map((action) => {
404
+ const recordedCase = takeCase(cases, action);
405
+ const settlements = recordedCase ? settlementDates(recordedCase) : [];
406
+ return compact({
407
+ caseId: echoText(recordedCase?.CaseID),
408
+ settlementDate: settlements.sort((a, b) => dateKey(b).localeCompare(dateKey(a)))[0],
409
+ penaltyAssessedInDollars: parseEchoNumber(action.PenaltyAmount),
410
+ actionType: echoText(action.ActionType),
411
+ actionDate: echoText(action.ActionDate),
412
+ statute: echoText(action.Statute),
413
+ sourceId: echoText(action.SourceID),
414
+ leadAgency: echoText(action.LeadAgency),
415
+ });
416
+ });
417
+ const air = base.programs.air ? programSummary('CAA') : undefined;
418
+ const water = base.programs.water ? programSummary('CWA') : undefined;
419
+ const quartersInViolation = parseEchoNumber(statusRow?.FacQtrsWithNC);
420
+ const profile = {
288
421
  ...base,
289
- ...(compliance && { compliance }),
422
+ triFacilityIds,
423
+ ...(tri && { triReleasesTransfersInLbs: tri.lbs, triReleasesTransfersYear: tri.year }),
424
+ ...(quartersInViolation !== undefined && { compliance: { quartersInViolation } }),
425
+ programCompliance,
290
426
  inspections,
427
+ inspectionsTotal: monitoring.length,
291
428
  formalActions,
292
- ...(airCompliance && { airCompliance }),
293
- ...(waterCompliance && { waterCompliance }),
429
+ ...(air?.currentStatus && {
430
+ airCompliance: compact({
431
+ programId: soleSourceId('ICIS-Air'),
432
+ status: air.currentStatus,
433
+ statusDate: air.periodEnd,
434
+ }),
435
+ }),
436
+ ...(water?.currentStatus && {
437
+ waterCompliance: compact({
438
+ status: water.currentStatus,
439
+ permitId: soleSourceId('ICIS-NPDES'),
440
+ }),
441
+ }),
294
442
  };
443
+ return status.failed ? { profile, notice: STATUS_LOOKUP_NOTICE } : { profile };
444
+ }
445
+ /**
446
+ * Best-effort echo_rest_services.get_facility_info?p_frs row, the only source of ECHO's
447
+ * facility-level compliance status. Two attempts within what is left of the call's budget;
448
+ * any failure but caller cancellation returns `failed` instead of throwing.
449
+ */
450
+ async fetchFacilityStatusRow(registryId, ctx, budgetMs) {
451
+ const url = this.buildUrl('echo_rest_services.get_facility_info', {
452
+ output: 'JSON',
453
+ p_frs: registryId,
454
+ });
455
+ ctx.log.debug('ECHO facility status lookup', {
456
+ endpoint: 'echo_rest_services.get_facility_info',
457
+ registryId,
458
+ });
459
+ try {
460
+ const data = await this.fetchJson(url, ctx, {
461
+ maxRetries: 1,
462
+ deadlineMs: Math.min(STATUS_LOOKUP_DEADLINE_MS, budgetMs),
463
+ });
464
+ return { row: data.Results?.Facilities?.find((f) => f.RegistryID === registryId) };
465
+ }
466
+ catch (error) {
467
+ if (ctx.signal?.aborted)
468
+ throw error;
469
+ ctx.log.warning('ECHO facility status lookup failed; returning the profile without it', {
470
+ registryId,
471
+ error: error instanceof Error ? error.message : String(error),
472
+ });
473
+ return { failed: true };
474
+ }
295
475
  }
296
476
  /**
297
477
  * Search EPA enforcement cases using a two-step ECHO case_rest_services flow.
@@ -301,31 +481,43 @@ export class EchoService {
301
481
  *
302
482
  * The single-step approach (reading Cases from get_case_info) always returns empty because
303
483
  * get_case_info is a discovery/cluster endpoint that does not include paginated case records.
484
+ *
485
+ * Filing dates are validated YYYY-MM-DD strings. A date bound sends both MM/DD/YYYY ends ECHO
486
+ * requires — an open end reads 01/01/1900 or today (UTC), and each end is clamped to that span —
487
+ * with the civil filing milestones. A range that clamps empty returns no cases without a
488
+ * request: ECHO answers an inverted range with an in-band error, not an empty result.
304
489
  */
305
490
  async searchViolations(params, ctx) {
491
+ const budget = searchBudget('case search', 'Narrow it with zip_code, program, or a filing-date range, or call again later.');
306
492
  const limit = params.limit ?? 50;
307
- const qparams = {
493
+ const { dateFiledStart, dateFiledEnd } = params;
494
+ const dated = dateFiledStart !== undefined || dateFiledEnd !== undefined;
495
+ const today = new Date().toISOString().slice(0, 10);
496
+ const fromDate = dateFiledStart !== undefined && dateFiledStart > EARLIEST_FILING_DATE
497
+ ? dateFiledStart
498
+ : EARLIEST_FILING_DATE;
499
+ const toDate = dateFiledEnd !== undefined && dateFiledEnd < today ? dateFiledEnd : today;
500
+ if (dated && fromDate > toDate)
501
+ return { cases: [], totalCount: 0 };
502
+ // Step 1: Get QueryID from get_case_info (returns cluster data only, no Cases[])
503
+ const infoUrl = this.buildUrl('case_rest_services.get_case_info', {
308
504
  output: 'JSON',
309
505
  responseset: limit,
310
- };
311
- if (params.state)
312
- qparams.p_state = params.state;
313
- if (params.zipCode)
314
- qparams.p_zip = params.zipCode;
315
- if (params.program)
316
- qparams.p_act = params.program;
317
- if (params.dateFiledStart)
318
- qparams.p_date_filed_from = params.dateFiledStart;
319
- if (params.dateFiledEnd)
320
- qparams.p_date_filed_to = params.dateFiledEnd;
321
- // Case type filter
322
- if (params.caseType && params.caseType !== 'all') {
323
- qparams.p_case_category = params.caseType === 'criminal' ? 'C' : 'V';
324
- }
325
- // Step 1: Get QueryID from get_case_info (returns cluster data only, no Cases[])
326
- const infoUrl = this.buildUrl('case_rest_services.get_case_info', qparams);
506
+ p_state: params.state,
507
+ p_fac_zip: params.zipCode,
508
+ p_law: params.program,
509
+ p_civil_criminal_indicator: params.caseType && CASE_TYPE_CODES[params.caseType],
510
+ ...(dated && {
511
+ p_from_date: toEchoDate(fromDate),
512
+ p_to_date: toEchoDate(toDate),
513
+ p_milestone: FILING_MILESTONES,
514
+ }),
515
+ });
327
516
  ctx.log.debug('ECHO case search step 1', { endpoint: 'case_rest_services.get_case_info' });
328
- const infoData = await this.fetchJson(infoUrl, ctx);
517
+ const infoData = await this.fetchJson(infoUrl, ctx, {
518
+ deadlineMs: budget.remainingMs(),
519
+ }).catch(budget.rethrow);
520
+ assertFiltersApplied('case_rest_services.get_case_info', infoUrl, infoData.Results?.QueryParameters);
329
521
  const queryId = infoData.Results?.QueryID;
330
522
  const totalCount = parseInt(infoData.Results?.QueryRows ?? '0', 10) || 0;
331
523
  if (!queryId || totalCount === 0) {
@@ -338,7 +530,9 @@ export class EchoService {
338
530
  pageno: 1,
339
531
  });
340
532
  ctx.log.debug('ECHO case search step 2', { endpoint: 'case_rest_services.get_qid', queryId });
341
- const data = await this.fetchJson(qidUrl, ctx);
533
+ const data = await this.fetchJson(qidUrl, ctx, {
534
+ deadlineMs: budget.remainingMs(),
535
+ }).catch(budget.rethrow);
342
536
  const raw = (data.Results?.Cases ?? []).slice(0, limit);
343
537
  const cases = raw.map((c) => {
344
538
  const fedPenalty = c.FedPenalty ? parseFloat(String(c.FedPenalty).replace(/[$,]/g, '')) : NaN;