@cliwant/mcp-sam-gov 0.3.0 → 1.0.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.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/src/echo.ts
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EPA ECHO REST services — keyless facility environmental compliance / enforcement
|
|
3
|
+
* screening (ADR-0009). A NEW capability axis for the product: facility &
|
|
4
|
+
* competitor environmental compliance-risk screening / due diligence (EPA
|
|
5
|
+
* enforcement, inspection, violation, and penalty history keyed to a facility),
|
|
6
|
+
* orthogonal to the spend/contract/regulatory layers.
|
|
7
|
+
*
|
|
8
|
+
* The THIRD source on the R2 `DataSource` port (ADR-0005, after Socrata/CKAN):
|
|
9
|
+
* `echo.ts` writes ZERO fetch/coercion/error code — it REUSES `getJson` /
|
|
10
|
+
* `driftError` / `num`·`str` / `withMeta`, and COPIES (does NOT import) the
|
|
11
|
+
* fixed-host SSRF + honesty PATTERN. Fully PUBLIC, KEYLESS (`keylessMode:true`,
|
|
12
|
+
* byte-clean init — NO headers, like ckan.ts). ECHO is neither Socrata nor CKAN:
|
|
13
|
+
* it is a bespoke Oracle-PL/SQL-fronted REST facade with a TWO-STEP QueryID
|
|
14
|
+
* pagination model and a 200-with-error-body failure mode.
|
|
15
|
+
*
|
|
16
|
+
* Step 1 (search): https://echodata.epa.gov/echo/echo_rest_services.get_facilities?output=JSON&p_st=…
|
|
17
|
+
* → { Results:{ Message, QueryRows:"N", …counts…, QueryID:"n" } } (NO rows)
|
|
18
|
+
* Step 2 (rows): https://echodata.epa.gov/echo/echo_rest_services.get_qid?output=JSON&qid=n&pageno=k
|
|
19
|
+
* → { Results:{ Message:"Working", Facilities:[ …rows… ] } }
|
|
20
|
+
* Detail (DFR): https://echodata.epa.gov/echo/dfr_rest_services.get_dfr?output=JSON&p_id={RegistryID}
|
|
21
|
+
* → { Results:{ Message:"Success", RegistryID, Reports, Permits, … } }
|
|
22
|
+
*
|
|
23
|
+
* ★ SSRF GUARD (policy① — the central design risk; a TIGHTER copy of the
|
|
24
|
+
* Socrata/CKAN fixed-host shape). The attack surface is SMALLER than CKAN's:
|
|
25
|
+
* (1) SINGLE fixed host constant `ECHO_HOST` — the caller NEVER supplies a host.
|
|
26
|
+
* (2) THREE fixed service-path constants (a frozen Set) — the caller NEVER
|
|
27
|
+
* supplies a path fragment; a service outside the Set ⇒ invalid_input before
|
|
28
|
+
* any fetch (the path-injection guard).
|
|
29
|
+
* (3) Every interpolated id is grammar-validated BEFORE use — `state` ∈ a frozen
|
|
30
|
+
* US state/territory enum (also the silent-zero guard, below); `naics`
|
|
31
|
+
* ^[0-9]{2,6}$ / `sic` ^[0-9]{2,4}$; `registryId` ^[0-9]{9,12}$ (FRS IDs are
|
|
32
|
+
* 12 digits; all-digit is the security property); the UPSTREAM-supplied
|
|
33
|
+
* `qid` is validated ^[0-9]+$ BECAUSE it is external (echodata.epa.gov mints
|
|
34
|
+
* it), before it is used in step 2; the internally-computed `pageno` is a
|
|
35
|
+
* plain integer. `facilityName` (p_fn) is a free-text filter VALUE — encoded
|
|
36
|
+
* through URLSearchParams, never touching the host/path.
|
|
37
|
+
* (4) Construct the URL, then ASSERT `new URL(built).hostname === ECHO_HOST` and
|
|
38
|
+
* `protocol === "https:"` ⇒ invalid_input on mismatch (belt-and-suspenders).
|
|
39
|
+
* B1 (redirect SSRF): every getJson sets `redirect:"error"` — a 3xx off
|
|
40
|
+
* echodata.epa.gov (migration / DNS-hijack / reused domain) throws; its body is
|
|
41
|
+
* never read. Adding a service/filter later = a CONSTANT edit + a live
|
|
42
|
+
* `output=JSON` verification — NEVER a free runtime host/path param.
|
|
43
|
+
*
|
|
44
|
+
* ★ 200-WITH-ERROR-BODY (the fake-empty trap — OBSERVED live, not defensive).
|
|
45
|
+
* A bogus `qid`, a bad DFR `p_id`, AND a queryset-limit overflow all return HTTP
|
|
46
|
+
* 200 carrying `{Results:{Error:{ErrorMessage}}}`. `errorFromResponse` keys off
|
|
47
|
+
* HTTP status and would pass a 200 straight through. So on EVERY response we
|
|
48
|
+
* detect `Results.Error` FIRST and THROW (classified) BEFORE reading
|
|
49
|
+
* QueryRows/Facilities — the ECHO analogue of CKAN's success:false-on-200 guard.
|
|
50
|
+
* Classification (by ErrorMessage):
|
|
51
|
+
* - "Queryset Limit would be exceeded" ⇒ invalid_input (narrow the query)
|
|
52
|
+
* - "…not found in ECHO" (recycled qid) ⇒ not_found, RETRYABLE (the QueryID
|
|
53
|
+
* is an ephemeral globally-recycled slot — a transient, not a missing
|
|
54
|
+
* facility; retry echo_search_facilities)
|
|
55
|
+
* - "ID … is invalid" (bad DFR id) ⇒ not_found (no report for that id)
|
|
56
|
+
* - anything else ⇒ schema_drift (surfaced, never
|
|
57
|
+
* silently swallowed)
|
|
58
|
+
*
|
|
59
|
+
* ★ TWO-STEP HIDDEN IN-CALL (ADR-0009 §1a). The QueryID is an ephemeral,
|
|
60
|
+
* globally-recycled, monotonically-incrementing cache slot (live-verified:
|
|
61
|
+
* IDs jumped 835→909 across a handful of calls) — NOT deterministic, NOT safe to
|
|
62
|
+
* persist across tool calls. `echo_search_facilities` therefore performs BOTH
|
|
63
|
+
* steps inside ONE invocation (get_facilities → capture QueryRows + fresh
|
|
64
|
+
* QueryID → immediately get_qid at the requested page) and NEVER exposes the
|
|
65
|
+
* QueryID to the caller. Paginating to page N re-runs get_facilities fresh.
|
|
66
|
+
* Two HTTP round-trips per search; robust against id recycling (memoize is
|
|
67
|
+
* unsafe here). Pagination is the standard offset/limit contract, translated to
|
|
68
|
+
* `pageno = offset/limit + 1` and `responseset = limit`; because ECHO can only
|
|
69
|
+
* page on page boundaries, `offset` MUST be an exact multiple of `limit`
|
|
70
|
+
* (else invalid_input locally, before any fetch).
|
|
71
|
+
*
|
|
72
|
+
* ★ M2 — NAICS vs SIC filtering, LIVE-VERIFIED 2026-07-12 (the data-lie guard).
|
|
73
|
+
* `p_st=DC` bare ⇒ QueryRows 4714. `p_st=DC&p_naics=325` / `=32511` / `=54` /
|
|
74
|
+
* even a bogus `=999999` ALL returned the identical 4714 ⇒ ECHO DROPS NAICS
|
|
75
|
+
* entirely (a real filter would return 0 for a nonexistent code). BUT
|
|
76
|
+
* `p_st=DC&p_sic=2911` ⇒ 1 and `&p_sic=9999`/`=8011` ⇒ 0 ⇒ SIC DOES narrow.
|
|
77
|
+
* So the two behave DIFFERENTLY (a MIXED outcome — a deviation from the ADR's
|
|
78
|
+
* unified Case-A/B framing):
|
|
79
|
+
* - `sic` = Case A (works) ⇒ a REAL filter; listed in filtersApplied.
|
|
80
|
+
* - `naics` = Case B (dropped) ⇒ BEST-EFFORT: marked best-effort in the
|
|
81
|
+
* tool-schema description, added to `_meta.filtersDropped` whenever passed,
|
|
82
|
+
* AND a `_meta.notes` disclosure warns the returned facilities are NOT
|
|
83
|
+
* guaranteed to match the NAICS code. NEVER silently presented as filtered.
|
|
84
|
+
*
|
|
85
|
+
* ★ HONESTY (`_meta`; REUSE withMeta/buildMeta). `totalAvailable = num(QueryRows)`
|
|
86
|
+
* — the EXACT upstream total, NEVER the page size. `returned =
|
|
87
|
+
* Results.Facilities.length`. `hasMore = offset + returned < total` (exact — no
|
|
88
|
+
* page-fullness hedge). Genuine-empty (`QueryRows:"0"`, no Results.Error) ⇒
|
|
89
|
+
* complete:true / totalAvailable:0. Outage/5xx/timeout ⇒ getJson throws (never a
|
|
90
|
+
* fake empty). `num`/`str` are null-never-0. Row-level currency/count fields
|
|
91
|
+
* (e.g. TotalPenalties "$1,056,616") pass through VERBATIM.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
95
|
+
import { getJson, driftError } from "./datasource.js";
|
|
96
|
+
import { num, str } from "./coerce.js";
|
|
97
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
98
|
+
|
|
99
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
100
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
101
|
+
// `num` from this module (echo.num === coerce.num === socrata.num === ckan.num).
|
|
102
|
+
export { num };
|
|
103
|
+
|
|
104
|
+
// ─── SSRF core: the single fixed host + three fixed service paths ──
|
|
105
|
+
const ECHO_HOST = "echodata.epa.gov";
|
|
106
|
+
const ECHO_BASE = `https://${ECHO_HOST}/echo/`;
|
|
107
|
+
|
|
108
|
+
// The ONLY three service paths we ever build (frozen constants — the caller never
|
|
109
|
+
// supplies a path fragment). A service outside this Set ⇒ invalid_input (the
|
|
110
|
+
// path-injection guard). get_facilities/get_qid live on echo_rest_services;
|
|
111
|
+
// get_dfr on dfr_rest_services.
|
|
112
|
+
const SVC_FACILITIES = "echo_rest_services.get_facilities";
|
|
113
|
+
const SVC_QID = "echo_rest_services.get_qid";
|
|
114
|
+
const SVC_DFR = "dfr_rest_services.get_dfr";
|
|
115
|
+
const ECHO_SERVICES: ReadonlySet<string> = new Set([
|
|
116
|
+
SVC_FACILITIES,
|
|
117
|
+
SVC_QID,
|
|
118
|
+
SVC_DFR,
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
// ─── Client-side value grammars (the silent-zero + SSRF guards) ───
|
|
122
|
+
// ECHO does NOT validate filter VALUES: an unknown value silently returns
|
|
123
|
+
// QueryRows:"0" (indistinguishable from a genuine-empty). So we validate
|
|
124
|
+
// client-side: `state` against the enum below (surfaced by the Zod enum in
|
|
125
|
+
// server.ts), naics/sic against a digit-length grammar, registryId all-digit.
|
|
126
|
+
const NAICS_RE = /^[0-9]{2,6}$/;
|
|
127
|
+
const SIC_RE = /^[0-9]{2,4}$/;
|
|
128
|
+
const REGISTRY_ID_RE = /^[0-9]{9,12}$/;
|
|
129
|
+
// The UPSTREAM-supplied QueryID — validated BECAUSE it is external (echodata mints
|
|
130
|
+
// it), before it is used to build the step-2 URL.
|
|
131
|
+
const QID_RE = /^[0-9]+$/;
|
|
132
|
+
|
|
133
|
+
// The frozen US state/territory enum (50 states + DC + the 5 territories). Built
|
|
134
|
+
// FROM this array by the Zod enum in server.ts (single source of truth); it is
|
|
135
|
+
// BOTH the SSRF value guard and the silent-zero guard (§1c-2).
|
|
136
|
+
export const ECHO_STATES = [
|
|
137
|
+
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL",
|
|
138
|
+
"GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME",
|
|
139
|
+
"MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH",
|
|
140
|
+
"NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI",
|
|
141
|
+
"SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI",
|
|
142
|
+
"WY", "AS", "GU", "MP", "PR", "VI",
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
export type EchoState = (typeof ECHO_STATES)[number];
|
|
146
|
+
|
|
147
|
+
// ─── Notes (honesty disclosures — ADR-0009 v2 required set) ───────
|
|
148
|
+
const DATA_CURRENCY_NOTE =
|
|
149
|
+
"ECHO Version 'ALL DATA v2017-06-16' is the schema-version label, not a data cutoff; data is refreshed weekly and current through 2026 (live-verified).";
|
|
150
|
+
const FACILITY_NAME_NOTE =
|
|
151
|
+
"facility-name filter (p_fn) is not validated by ECHO — a typo silently returns 0 results, not an error; verify spelling if the result is empty.";
|
|
152
|
+
const NAICS_BEST_EFFORT_NOTE =
|
|
153
|
+
"ECHO did not narrow by NAICS at this (or any) scale — the p_naics filter is dropped upstream (live-verified 2026-07-12) — so the returned facilities are NOT guaranteed to match the NAICS code; naics is listed in _meta.filtersDropped. Use `sic` (which DOES narrow), facilityName, majorOnly, or federalOnly to scope by industry/type, and verify NAICS client-side.";
|
|
154
|
+
const NUMERIC_STRING_NOTE =
|
|
155
|
+
"Count/amount fields arrive as strings; QueryRows is coerced for the exact total, but row-level currency fields (e.g. TotalPenalties '$1,056,616') keep their $/comma formatting — parse client-side. A missing value is null, never 0.";
|
|
156
|
+
const TWO_STEP_NOTE =
|
|
157
|
+
"Results are the ECHO all-program facility compliance search (CAA/CWA/RCRA/SDWA); totalAvailable is the EXACT QueryRows total; rows are fetched via a hidden two-step QueryID pagination (the QueryID is ephemeral/globally-recycled and never exposed).";
|
|
158
|
+
|
|
159
|
+
const SOURCE = `${ECHO_HOST} via ECHO REST (keyless)`;
|
|
160
|
+
|
|
161
|
+
// ─── fetch layer (SSRF-guarded; reuses the R2 port) ───────────────
|
|
162
|
+
export type EchoRow = Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* GET one ECHO REST service. SSRF guard: `service` ∈ the frozen 3-member Set
|
|
166
|
+
* (the path-injection guard), params via URLSearchParams (encoded values, no
|
|
167
|
+
* host-alteration surface), then the CONSTRUCTED URL's hostname === ECHO_HOST
|
|
168
|
+
* (https) assertion. Sets `redirect:"error"` (B1); NO headers (keyless — ECHO is
|
|
169
|
+
* anonymous, byte-clean init). Reuses errors.ts retry/timeout/taxonomy (429 →
|
|
170
|
+
* rate_limited; 5xx → upstream_unavailable; 404 → not_found; 400 → invalid_input).
|
|
171
|
+
* Returns the parsed JSON (unknown; the caller validates the Results envelope).
|
|
172
|
+
*/
|
|
173
|
+
export async function echoGet(
|
|
174
|
+
service: string,
|
|
175
|
+
params: URLSearchParams,
|
|
176
|
+
): Promise<unknown> {
|
|
177
|
+
if (!ECHO_SERVICES.has(service)) {
|
|
178
|
+
throw new ToolErrorCarrier({
|
|
179
|
+
kind: "invalid_input",
|
|
180
|
+
message: `ECHO service ${JSON.stringify(service)} is not one of the three fixed service paths (get_facilities / get_qid / get_dfr) — refusing to fetch (SSRF path guard).`,
|
|
181
|
+
retryable: false,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const url = `${ECHO_BASE}${service}?${params.toString()}`;
|
|
185
|
+
// Belt-and-suspenders: the FIXED host + FIXED service leave nothing to steer the
|
|
186
|
+
// authority; assert the built URL cannot have been moved off-host.
|
|
187
|
+
const built = new URL(url);
|
|
188
|
+
if (built.hostname !== ECHO_HOST || built.protocol !== "https:") {
|
|
189
|
+
throw new ToolErrorCarrier({
|
|
190
|
+
kind: "invalid_input",
|
|
191
|
+
message: `Constructed ECHO URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${ECHO_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
192
|
+
retryable: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Shared fetch envelope (ADR-0005): keyless ⇒ NO headers key (byte-clean init);
|
|
196
|
+
// B1 redirect:"error"; the label surfaces as ToolError.upstreamEndpoint.
|
|
197
|
+
return getJson(url, { label: "echo:" + service, redirect: "error" });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Results envelope + the 200-with-error-body guard ─────────────
|
|
201
|
+
type EchoResults = Record<string, unknown>;
|
|
202
|
+
|
|
203
|
+
/** Unwrap `{Results:{…}}`; a missing/non-object Results is schema drift. */
|
|
204
|
+
function unwrapResults(service: string, body: unknown): EchoResults {
|
|
205
|
+
const r = (body as { Results?: unknown } | null | undefined)?.Results;
|
|
206
|
+
if (r === null || typeof r !== "object" || Array.isArray(r)) {
|
|
207
|
+
throw driftError(
|
|
208
|
+
"echo:" + service,
|
|
209
|
+
`echo:${service} returned an unexpected shape (missing Results object).`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return r as EchoResults;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The 200-with-error-body guard (§1c-1). If `Results.Error` is present, classify
|
|
217
|
+
* by ErrorMessage and THROW (never read QueryRows/Facilities). This MUST run on
|
|
218
|
+
* every response BEFORE any record access — a bogus qid / bad DFR id / queryset
|
|
219
|
+
* overflow all return HTTP 200 carrying the error, so the HTTP-status taxonomy
|
|
220
|
+
* never sees it.
|
|
221
|
+
*/
|
|
222
|
+
function guardResultsError(service: string, results: EchoResults): void {
|
|
223
|
+
const err = (results as { Error?: unknown }).Error;
|
|
224
|
+
if (err === undefined || err === null) return;
|
|
225
|
+
const label = "echo:" + service;
|
|
226
|
+
const msg =
|
|
227
|
+
str((err as { ErrorMessage?: unknown }).ErrorMessage) ??
|
|
228
|
+
(typeof err === "string" ? str(err) : null) ??
|
|
229
|
+
"(no ErrorMessage)";
|
|
230
|
+
|
|
231
|
+
// M1 — a queryset-limit overflow is a known upstream ADVISORY, not a schema
|
|
232
|
+
// regression. Map to invalid_input with actionable guidance. (naics is dropped
|
|
233
|
+
// upstream — M2 — so it is NOT suggested as a narrowing filter.)
|
|
234
|
+
if (msg.includes("Queryset Limit would be exceeded")) {
|
|
235
|
+
throw new ToolErrorCarrier({
|
|
236
|
+
kind: "invalid_input",
|
|
237
|
+
message: `ECHO queryset limit exceeded — the query would return too many facilities. Narrow with sic, facilityName, majorOnly, or federalOnly (note: naics is ignored by ECHO). Upstream: ${msg}`,
|
|
238
|
+
retryable: false,
|
|
239
|
+
upstreamEndpoint: label,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// A recycled/unknown QueryID on the get_qid path (m6). The QueryID is an
|
|
243
|
+
// ephemeral globally-recycled slot — a transient, NOT a missing facility.
|
|
244
|
+
if (/not found in ECHO/i.test(msg)) {
|
|
245
|
+
throw new ToolErrorCarrier({
|
|
246
|
+
kind: "not_found",
|
|
247
|
+
message: `ECHO QueryID was recycled by concurrent traffic before the page fetch (ephemeral shared slot); retry echo_search_facilities — this is a transient, not a missing facility. Upstream: ${msg}`,
|
|
248
|
+
retryable: true,
|
|
249
|
+
upstreamEndpoint: label,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// A bad DFR RegistryID ⇒ no Detailed Facility Report for that id ⇒ not_found.
|
|
253
|
+
if (/is invalid/i.test(msg)) {
|
|
254
|
+
throw new ToolErrorCarrier({
|
|
255
|
+
kind: "not_found",
|
|
256
|
+
message: `ECHO has no Detailed Facility Report for that RegistryID (the id is not recognized). Upstream: ${msg}`,
|
|
257
|
+
retryable: false,
|
|
258
|
+
upstreamEndpoint: label,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Anything else is an unrecognized ECHO error — surfaced as schema drift, never
|
|
262
|
+
// silently swallowed into a fake empty.
|
|
263
|
+
throw driftError(
|
|
264
|
+
label,
|
|
265
|
+
`echo:${service} returned an unrecognized Results.Error — treating as schema drift. Upstream: ${msg}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Tool 1: echo_search_facilities ───────────────────────────────
|
|
270
|
+
/**
|
|
271
|
+
* Search EPA-regulated facilities by state (+ optional sic / facilityName /
|
|
272
|
+
* majorOnly / federalOnly / naics-best-effort) with compliance/enforcement
|
|
273
|
+
* screening fields. The workhorse: state + industry + name + major/federal
|
|
274
|
+
* across CAA/CWA/RCRA/SDWA. `state` is REQUIRED (an unscoped national query is
|
|
275
|
+
* ~5.6M rows AND the state enum is the silent-zero guard).
|
|
276
|
+
*
|
|
277
|
+
* Hides the two-step QueryID pagination behind ONE call: internally get_facilities
|
|
278
|
+
* (→ exact QueryRows + a fresh QueryID) then get_qid?pageno=offset/limit+1 (→ the
|
|
279
|
+
* rows). The QueryID is captured and consumed in-call, NEVER exposed. Rows pass
|
|
280
|
+
* through verbatim. HONESTY: totalAvailable = num(QueryRows) (exact, never the page
|
|
281
|
+
* size); genuine-empty (QueryRows:"0") ⇒ complete:true/total:0; a Results.Error ⇒
|
|
282
|
+
* classified throw (never a fake empty); an outage ⇒ getJson throws.
|
|
283
|
+
*/
|
|
284
|
+
export async function searchFacilities(args: {
|
|
285
|
+
state: EchoState;
|
|
286
|
+
naics?: string;
|
|
287
|
+
sic?: string;
|
|
288
|
+
facilityName?: string;
|
|
289
|
+
majorOnly?: boolean;
|
|
290
|
+
federalOnly?: boolean;
|
|
291
|
+
limit?: number;
|
|
292
|
+
offset?: number;
|
|
293
|
+
}): Promise<MetaBundle> {
|
|
294
|
+
const limit = args.limit ?? 100;
|
|
295
|
+
const offset = args.offset ?? 0;
|
|
296
|
+
|
|
297
|
+
// Page-boundary guard (§1b): ECHO can only page on pageno boundaries, so offset
|
|
298
|
+
// MUST be an exact multiple of limit. Rejected LOCALLY, before any fetch.
|
|
299
|
+
if (offset % limit !== 0) {
|
|
300
|
+
throw new ToolErrorCarrier({
|
|
301
|
+
kind: "invalid_input",
|
|
302
|
+
message: `ECHO pages on fixed boundaries; offset (${offset}) must be an exact multiple of limit (${limit}).`,
|
|
303
|
+
retryable: false,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const pageno = offset / limit + 1;
|
|
307
|
+
|
|
308
|
+
// Belt-and-suspenders value grammars (behind the server's Zod enum/regex).
|
|
309
|
+
if (args.naics !== undefined && !NAICS_RE.test(args.naics)) {
|
|
310
|
+
throw new ToolErrorCarrier({
|
|
311
|
+
kind: "invalid_input",
|
|
312
|
+
message: `Invalid naics ${JSON.stringify(args.naics)} — expected 2–6 digits.`,
|
|
313
|
+
retryable: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (args.sic !== undefined && !SIC_RE.test(args.sic)) {
|
|
317
|
+
throw new ToolErrorCarrier({
|
|
318
|
+
kind: "invalid_input",
|
|
319
|
+
message: `Invalid sic ${JSON.stringify(args.sic)} — expected 2–4 digits.`,
|
|
320
|
+
retryable: false,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Step 1: get_facilities (→ exact total + a fresh QueryID; NO rows). ──
|
|
325
|
+
const p1 = new URLSearchParams();
|
|
326
|
+
p1.set("output", "JSON");
|
|
327
|
+
p1.set("p_st", args.state);
|
|
328
|
+
// naics is DROPPED upstream (M2) but still sent (harmless; disclosed as dropped
|
|
329
|
+
// below). sic DOES narrow (M2) — a real filter.
|
|
330
|
+
if (args.naics !== undefined) p1.set("p_naics", args.naics);
|
|
331
|
+
if (args.sic !== undefined) p1.set("p_sic", args.sic);
|
|
332
|
+
if (args.facilityName !== undefined) p1.set("p_fn", args.facilityName);
|
|
333
|
+
if (args.majorOnly === true) p1.set("p_maj", "Y");
|
|
334
|
+
if (args.federalOnly === true) p1.set("p_ff", "Y");
|
|
335
|
+
p1.set("responseset", String(limit));
|
|
336
|
+
|
|
337
|
+
const results1 = unwrapResults(SVC_FACILITIES, await echoGet(SVC_FACILITIES, p1));
|
|
338
|
+
guardResultsError(SVC_FACILITIES, results1); // 200-with-error-body FIRST
|
|
339
|
+
|
|
340
|
+
// totalAvailable = num(QueryRows) — the EXACT total, NEVER the page size. A
|
|
341
|
+
// non-numeric / absent QueryRows with no Results.Error ⇒ hard drift.
|
|
342
|
+
const total = num((results1 as { QueryRows?: unknown }).QueryRows);
|
|
343
|
+
if (total === null) {
|
|
344
|
+
throw driftError(
|
|
345
|
+
"echo:" + SVC_FACILITIES,
|
|
346
|
+
`echo:${SVC_FACILITIES} returned no numeric QueryRows and no Results.Error — treating as schema drift.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Program-count summary (all pass-through; TotalPenalties is a verbatim currency
|
|
351
|
+
// string that num() cannot parse — kept as-is).
|
|
352
|
+
const summary = {
|
|
353
|
+
queryRows: total,
|
|
354
|
+
totalPenalties: str((results1 as { TotalPenalties?: unknown }).TotalPenalties),
|
|
355
|
+
programCounts: {
|
|
356
|
+
caa: num((results1 as { CAARows?: unknown }).CAARows),
|
|
357
|
+
cwa: num((results1 as { CWARows?: unknown }).CWARows),
|
|
358
|
+
rcra: num((results1 as { RCRRows?: unknown }).RCRRows),
|
|
359
|
+
tri: num((results1 as { TRIRows?: unknown }).TRIRows),
|
|
360
|
+
inspections: num((results1 as { INSPRows?: unknown }).INSPRows),
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Honesty accounting (M2): sic/facilityName/major/federal are HONORED; naics is
|
|
365
|
+
// DROPPED upstream → filtersDropped + a disclosure note.
|
|
366
|
+
const filtersApplied: string[] = ["state"];
|
|
367
|
+
if (args.sic !== undefined) filtersApplied.push("sic");
|
|
368
|
+
if (args.facilityName !== undefined) filtersApplied.push("facilityName");
|
|
369
|
+
if (args.majorOnly === true) filtersApplied.push("majorOnly");
|
|
370
|
+
if (args.federalOnly === true) filtersApplied.push("federalOnly");
|
|
371
|
+
const filtersDropped: string[] = [];
|
|
372
|
+
const notes: string[] = [
|
|
373
|
+
TWO_STEP_NOTE,
|
|
374
|
+
DATA_CURRENCY_NOTE,
|
|
375
|
+
FACILITY_NAME_NOTE,
|
|
376
|
+
NUMERIC_STRING_NOTE,
|
|
377
|
+
];
|
|
378
|
+
if (args.naics !== undefined) {
|
|
379
|
+
filtersDropped.push("naics");
|
|
380
|
+
notes.push(NAICS_BEST_EFFORT_NOTE);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Genuine-empty (QueryRows:"0", no Results.Error): honest complete:true/total:0
|
|
384
|
+
// WITHOUT a step-2 fetch (there is nothing to page).
|
|
385
|
+
if (total === 0) {
|
|
386
|
+
return withMeta(
|
|
387
|
+
{ state: args.state, facilities: [] as EchoRow[], summary },
|
|
388
|
+
{
|
|
389
|
+
source: SOURCE,
|
|
390
|
+
keylessMode: true,
|
|
391
|
+
returned: 0,
|
|
392
|
+
totalAvailable: 0,
|
|
393
|
+
filtersApplied,
|
|
394
|
+
filtersDropped,
|
|
395
|
+
fieldsUnavailable: [],
|
|
396
|
+
pagination: { offset, limit, hasMore: false, nextOffset: null },
|
|
397
|
+
notes,
|
|
398
|
+
} satisfies Partial<ResponseMeta>,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Capture + validate the UPSTREAM QueryID before using it in step 2.
|
|
403
|
+
const qid = str((results1 as { QueryID?: unknown }).QueryID);
|
|
404
|
+
if (qid === null || !QID_RE.test(qid)) {
|
|
405
|
+
throw driftError(
|
|
406
|
+
"echo:" + SVC_FACILITIES,
|
|
407
|
+
`echo:${SVC_FACILITIES} returned a missing/non-numeric QueryID — cannot fetch the result page (schema drift).`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Step 2: get_qid (→ the actual rows for the requested page). ──
|
|
412
|
+
const p2 = new URLSearchParams();
|
|
413
|
+
p2.set("output", "JSON");
|
|
414
|
+
p2.set("qid", qid);
|
|
415
|
+
p2.set("pageno", String(pageno));
|
|
416
|
+
|
|
417
|
+
const results2 = unwrapResults(SVC_QID, await echoGet(SVC_QID, p2));
|
|
418
|
+
guardResultsError(SVC_QID, results2); // recycled-qid ⇒ not_found retryable
|
|
419
|
+
|
|
420
|
+
const facilities = (results2 as { Facilities?: unknown }).Facilities;
|
|
421
|
+
if (!Array.isArray(facilities)) {
|
|
422
|
+
throw driftError(
|
|
423
|
+
"echo:" + SVC_QID,
|
|
424
|
+
`echo:${SVC_QID} returned a non-array Facilities with no Results.Error — treating as schema drift (never a fake empty).`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const returned = facilities.length;
|
|
429
|
+
// hasMore is EXACT (QueryRows is an exact total — no page-fullness hedge).
|
|
430
|
+
const hasMore = offset + returned < total;
|
|
431
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
432
|
+
|
|
433
|
+
return withMeta(
|
|
434
|
+
{ state: args.state, facilities: facilities as EchoRow[], summary },
|
|
435
|
+
{
|
|
436
|
+
source: SOURCE,
|
|
437
|
+
keylessMode: true,
|
|
438
|
+
returned,
|
|
439
|
+
totalAvailable: total,
|
|
440
|
+
filtersApplied,
|
|
441
|
+
filtersDropped,
|
|
442
|
+
fieldsUnavailable: [],
|
|
443
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
444
|
+
notes,
|
|
445
|
+
} satisfies Partial<ResponseMeta>,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─── Tool 2: echo_facility_report ─────────────────────────────────
|
|
450
|
+
/**
|
|
451
|
+
* Fetch the Detailed Facility Report (DFR) for ONE facility by its FRS RegistryID
|
|
452
|
+
* (from echo_search_facilities rows): the per-facility compliance / enforcement /
|
|
453
|
+
* inspection / permit deep-dive for competitor / acquisition-target due diligence.
|
|
454
|
+
* Single record (no pagination). A bad/unknown RegistryID ⇒ the 200-with-error-
|
|
455
|
+
* body guard classifies "ID … is invalid" ⇒ not_found (never a fabricated report).
|
|
456
|
+
*/
|
|
457
|
+
export async function facilityReport(args: {
|
|
458
|
+
registryId: string;
|
|
459
|
+
}): Promise<MetaBundle> {
|
|
460
|
+
// Belt-and-suspenders (behind the server's Zod ^[0-9]{9,12}$).
|
|
461
|
+
if (!REGISTRY_ID_RE.test(args.registryId)) {
|
|
462
|
+
throw new ToolErrorCarrier({
|
|
463
|
+
kind: "invalid_input",
|
|
464
|
+
message: `Invalid registryId ${JSON.stringify(args.registryId)} — expected an all-digit FRS RegistryID (9–12 digits).`,
|
|
465
|
+
retryable: false,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const params = new URLSearchParams();
|
|
470
|
+
params.set("output", "JSON");
|
|
471
|
+
params.set("p_id", args.registryId);
|
|
472
|
+
|
|
473
|
+
const results = unwrapResults(SVC_DFR, await echoGet(SVC_DFR, params));
|
|
474
|
+
guardResultsError(SVC_DFR, results); // "ID … is invalid" ⇒ not_found
|
|
475
|
+
|
|
476
|
+
return withMeta(
|
|
477
|
+
{
|
|
478
|
+
registryId: str((results as { RegistryID?: unknown }).RegistryID) ?? args.registryId,
|
|
479
|
+
report: results,
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
source: SOURCE,
|
|
483
|
+
keylessMode: true,
|
|
484
|
+
returned: 1,
|
|
485
|
+
totalAvailable: 1,
|
|
486
|
+
filtersApplied: ["registryId"],
|
|
487
|
+
filtersDropped: [],
|
|
488
|
+
fieldsUnavailable: [],
|
|
489
|
+
notes: [
|
|
490
|
+
"Single Detailed Facility Report (DFR) — the full compliance/enforcement/inspection/permit detail for one facility; no pagination.",
|
|
491
|
+
DATA_CURRENCY_NOTE,
|
|
492
|
+
NUMERIC_STRING_NOTE,
|
|
493
|
+
],
|
|
494
|
+
} satisfies Partial<ResponseMeta>,
|
|
495
|
+
);
|
|
496
|
+
}
|