@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.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. 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
+ }