@cliwant/mcp-sam-gov 1.3.0 → 1.5.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/README.ja.md +20 -12
- package/README.ko.md +20 -12
- package/README.md +62 -14
- package/dist/bea.d.ts +1 -1
- package/dist/bea.js +1 -1
- package/dist/cbp-border.d.ts +51 -0
- package/dist/cbp-border.d.ts.map +1 -0
- package/dist/cbp-border.js +123 -0
- package/dist/cbp-border.js.map +1 -0
- package/dist/census-economic.d.ts +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +12 -6
- package/dist/census-economic.js.map +1 -1
- package/dist/cms-facility.d.ts +112 -0
- package/dist/cms-facility.d.ts.map +1 -0
- package/dist/cms-facility.js +311 -0
- package/dist/cms-facility.js.map +1 -0
- package/dist/cms-hospital.d.ts +105 -0
- package/dist/cms-hospital.d.ts.map +1 -0
- package/dist/cms-hospital.js +290 -0
- package/dist/cms-hospital.js.map +1 -0
- package/dist/cms-supplier.d.ts +133 -0
- package/dist/cms-supplier.d.ts.map +1 -0
- package/dist/cms-supplier.js +414 -0
- package/dist/cms-supplier.js.map +1 -0
- package/dist/cms-utilization.d.ts +113 -0
- package/dist/cms-utilization.d.ts.map +1 -0
- package/dist/cms-utilization.js +328 -0
- package/dist/cms-utilization.js.map +1 -0
- package/dist/courtlistener.d.ts +115 -0
- package/dist/courtlistener.d.ts.map +1 -0
- package/dist/courtlistener.js +398 -0
- package/dist/courtlistener.js.map +1 -0
- package/dist/cpsc.d.ts +81 -0
- package/dist/cpsc.d.ts.map +1 -0
- package/dist/cpsc.js +283 -0
- package/dist/cpsc.js.map +1 -0
- package/dist/datagov-catalog.d.ts.map +1 -1
- package/dist/datagov-catalog.js +16 -2
- package/dist/datagov-catalog.js.map +1 -1
- package/dist/dol.d.ts +2 -2
- package/dist/dol.js +5 -5
- package/dist/dol.js.map +1 -1
- package/dist/ecfr.d.ts +2 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +24 -10
- package/dist/ecfr.js.map +1 -1
- package/dist/edgar.d.ts.map +1 -1
- package/dist/edgar.js +26 -6
- package/dist/edgar.js.map +1 -1
- package/dist/epa-envirofacts.d.ts +97 -0
- package/dist/epa-envirofacts.d.ts.map +1 -0
- package/dist/epa-envirofacts.js +305 -0
- package/dist/epa-envirofacts.js.map +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -1
- package/dist/far.d.ts.map +1 -1
- package/dist/far.js +3 -1
- package/dist/far.js.map +1 -1
- package/dist/federal-register.d.ts +2 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +26 -10
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +36 -0
- package/dist/fema.d.ts.map +1 -1
- package/dist/fema.js +124 -0
- package/dist/fema.js.map +1 -1
- package/dist/fred.d.ts +1 -1
- package/dist/fred.js +1 -1
- package/dist/gov-domains.d.ts +66 -0
- package/dist/gov-domains.d.ts.map +1 -0
- package/dist/gov-domains.js +211 -0
- package/dist/gov-domains.js.map +1 -0
- package/dist/keys.d.ts +6 -5
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +25 -6
- package/dist/keys.js.map +1 -1
- package/dist/nhtsa.d.ts +91 -0
- package/dist/nhtsa.d.ts.map +1 -0
- package/dist/nhtsa.js +263 -0
- package/dist/nhtsa.js.map +1 -0
- package/dist/nist-controls.d.ts +48 -0
- package/dist/nist-controls.d.ts.map +1 -0
- package/dist/nist-controls.js +174 -0
- package/dist/nist-controls.js.map +1 -0
- package/dist/nonprofit.d.ts +116 -0
- package/dist/nonprofit.d.ts.map +1 -0
- package/dist/nonprofit.js +342 -0
- package/dist/nonprofit.js.map +1 -0
- package/dist/nws-weather.d.ts +57 -0
- package/dist/nws-weather.d.ts.map +1 -0
- package/dist/nws-weather.js +131 -0
- package/dist/nws-weather.js.map +1 -0
- package/dist/openfda-device.d.ts +85 -0
- package/dist/openfda-device.d.ts.map +1 -0
- package/dist/openfda-device.js +277 -0
- package/dist/openfda-device.js.map +1 -0
- package/dist/openfda-drugsfda.d.ts +72 -0
- package/dist/openfda-drugsfda.d.ts.map +1 -0
- package/dist/openfda-drugsfda.js +230 -0
- package/dist/openfda-drugsfda.js.map +1 -0
- package/dist/openfda.d.ts +133 -0
- package/dist/openfda.d.ts.map +1 -0
- package/dist/openfda.js +425 -0
- package/dist/openfda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +996 -16
- package/dist/server.js.map +1 -1
- package/dist/treasury.d.ts +2 -0
- package/dist/treasury.d.ts.map +1 -1
- package/dist/treasury.js +7 -0
- package/dist/treasury.js.map +1 -1
- package/dist/usaspending.d.ts +32 -1
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +143 -16
- package/dist/usaspending.js.map +1 -1
- package/package.json +3 -2
- package/src/bea.ts +1 -1
- package/src/cbp-border.ts +177 -0
- package/src/census-economic.ts +12 -6
- package/src/cms-facility.ts +379 -0
- package/src/cms-hospital.ts +344 -0
- package/src/cms-supplier.ts +527 -0
- package/src/cms-utilization.ts +389 -0
- package/src/courtlistener.ts +465 -0
- package/src/cpsc.ts +333 -0
- package/src/datagov-catalog.ts +18 -2
- package/src/dol.ts +5 -5
- package/src/ecfr.ts +27 -10
- package/src/edgar.ts +39 -7
- package/src/epa-envirofacts.ts +358 -0
- package/src/errors.ts +11 -0
- package/src/far.ts +3 -1
- package/src/federal-register.ts +29 -10
- package/src/fema.ts +139 -0
- package/src/fred.ts +1 -1
- package/src/gov-domains.ts +237 -0
- package/src/keys.ts +27 -6
- package/src/nhtsa.ts +352 -0
- package/src/nist-controls.ts +219 -0
- package/src/nonprofit.ts +460 -0
- package/src/nws-weather.ts +167 -0
- package/src/openfda-device.ts +356 -0
- package/src/openfda-drugsfda.ts +313 -0
- package/src/openfda.ts +518 -0
- package/src/server.ts +1127 -27
- package/src/treasury.ts +7 -0
- package/src/usaspending.ts +189 -17
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epa-envirofacts.ts — EPA Envirofacts RESTful data service (`data.epa.gov`,
|
|
3
|
+
* `/efservice`), the `tri_facility` table (ADR-0059). KEYLESS.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT ADDS: `epa_tri_facilities` — a location/compliance lane: look up EPA
|
|
6
|
+
* Toxics Release Inventory (TRI) reporting facilities by state / facility-name /
|
|
7
|
+
* county. This is the demand-side environmental-footprint complement to the
|
|
8
|
+
* market-sizing (Census CBP) and macro (FRED) sources — a place-of-performance
|
|
9
|
+
* environmental screen for a given geography.
|
|
10
|
+
*
|
|
11
|
+
* ★THE TWO-REQUEST PATTERN (the load-bearing P1 honesty): the efservice REST API
|
|
12
|
+
* embeds filters as PATH SEGMENTS and reports NO total in the data slice. So the
|
|
13
|
+
* EXACT total comes from a SEPARATE count sub-query — the identical filter path
|
|
14
|
+
* with a `/count/JSON` tail returns `[{"TOTALQUERYRESULTS": 1247}]`. This tool
|
|
15
|
+
* runs the count FIRST (best-effort) then the data slice: totalAvailable =
|
|
16
|
+
* TOTALQUERYRESULTS (P1, EXACT), NEVER the returned rows' length. If the count
|
|
17
|
+
* sub-query fails or is absent, totalAvailable falls to null + a disclosing note
|
|
18
|
+
* (never a length-faked total) and the data slice still returns.
|
|
19
|
+
*
|
|
20
|
+
* The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
|
|
21
|
+
* (redirect:"error") / `driftError` (datasource.ts), `str`/`num` (coerce.ts,
|
|
22
|
+
* null-never-empty-string / null-never-0), and `withMeta`·`buildMeta` (meta.ts,
|
|
23
|
+
* offset pagination + totalAvailable). It MIRRORS census-economic.ts's
|
|
24
|
+
* fixed-host SSRF idiom (a single host const + a post-construction hostname/
|
|
25
|
+
* protocol assertion + redirect:"error") and datagov-catalog.ts's schema_drift
|
|
26
|
+
* catch-ladder (ToolErrorCarrier rethrow FIRST so a 5xx keeps its taxonomy →
|
|
27
|
+
* SyntaxError→driftError → bare rethrow).
|
|
28
|
+
*
|
|
29
|
+
* GET https://data.epa.gov/efservice/tri_facility/state_abbr/{ST}
|
|
30
|
+
* [/facility_name/CONTAINING/{NAME}] [/county_name/CONTAINING/{COUNTY}]
|
|
31
|
+
* /count/JSON → [{ "TOTALQUERYRESULTS": <int> }] (the total)
|
|
32
|
+
* GET …same filter path… /rows/{offset}:{offset+limit-1}/JSON
|
|
33
|
+
* → [{ tri_facility_id, facility_name, street_address, city_name,
|
|
34
|
+
* county_name, state_abbr, zip_code, region, fac_closed_ind, … }, …]
|
|
35
|
+
*
|
|
36
|
+
* ★ SSRF (the load-bearing guard — values ride as PATH SEGMENTS, not query params):
|
|
37
|
+
* the host is a compile-time literal (`EPA_HOST`); the table + column names + the
|
|
38
|
+
* `CONTAINING` operator + `rows`/`count`/`JSON` are all MODULE literals. Every
|
|
39
|
+
* USER value is BOTH charclass-validated (state `^[A-Za-z]{2}$`; facilityName/
|
|
40
|
+
* county letters/digits/space/&/-/. only, rejecting `/` and `..` path-traversal)
|
|
41
|
+
* AND `encodeURIComponent`-encoded before it joins the path. `offset`/`limit` are
|
|
42
|
+
* coerced to non-negative integers. A post-construction hostname/protocol
|
|
43
|
+
* assertion + `redirect:"error"` fail closed on any off-host 3xx.
|
|
44
|
+
*
|
|
45
|
+
* ★ HONESTY (ADR-0059 P1–P5, live-verified 2026-07-15 on data.epa.gov):
|
|
46
|
+
* [input] require at least `state` OR `facilityName` — an all-empty query is
|
|
47
|
+
* REFUSED with invalid_input (0 fetch) so the whole national table is
|
|
48
|
+
* never scanned.
|
|
49
|
+
* [P1] totalAvailable = the count sub-query's TOTALQUERYRESULTS (EXACT — e.g.
|
|
50
|
+
* VA = 1247), NOT the slice length. hasMore = offset+returned < total.
|
|
51
|
+
* Count fails/absent ⇒ totalAvailable:null + a disclosing note.
|
|
52
|
+
* [P2] an empty array ⇒ honest empty (returned:0, complete:true). getJson maps
|
|
53
|
+
* a 4xx/5xx via errorFromResponse and THROWS (503 ⇒ upstream_unavailable,
|
|
54
|
+
* 400 ⇒ invalid_input, 404 ⇒ not_found); a 200 non-array/non-JSON body ⇒
|
|
55
|
+
* schema_drift (NEVER a fabricated empty).
|
|
56
|
+
* [P3] fac_closed_ind ("0"/"1" live; "N"/"Y" per the schema) ⇒ a normalized
|
|
57
|
+
* boolean `closed` — an UNRECOGNIZED value ⇒ null (never a fabricated
|
|
58
|
+
* false). Addresses/names are strings via `str` (null-never-empty-string).
|
|
59
|
+
* [P4] a data body that is not an array ⇒ driftError; a count body missing
|
|
60
|
+
* TOTALQUERYRESULTS ⇒ totalAvailable:null (handled, not a crash).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
64
|
+
import { getJson, driftError } from "./datasource.js";
|
|
65
|
+
import { str, num } from "./coerce.js";
|
|
66
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
67
|
+
|
|
68
|
+
// Re-export the shared honesty coercions (single audited copy in ./coerce.js) so a
|
|
69
|
+
// regression fails together across sources. NO local num/str.
|
|
70
|
+
export { num, str };
|
|
71
|
+
|
|
72
|
+
// ─── SSRF core: the single fixed host + module-literal path pieces ──
|
|
73
|
+
const EPA_HOST = "data.epa.gov";
|
|
74
|
+
const EPA_TABLE = "tri_facility";
|
|
75
|
+
// HOST+path label — surfaces in ToolError.upstreamEndpoint; keyless, so no token
|
|
76
|
+
// can ever appear here.
|
|
77
|
+
const EPA_LABEL = "epa-envirofacts:/efservice/tri_facility";
|
|
78
|
+
|
|
79
|
+
// ─── Validation charclasses (SSRF + "verify the input" honesty) ───
|
|
80
|
+
// state rides as a PATH SEGMENT → strictly 2 ASCII letters (the efservice filter is
|
|
81
|
+
// case-insensitive; live-verified `va` === `VA`).
|
|
82
|
+
const STATE_RE = /^[A-Za-z]{2}$/;
|
|
83
|
+
// facilityName / county ride as PATH SEGMENTS after `.../CONTAINING/`. Allow only
|
|
84
|
+
// letters / digits / space / & / - / . (a superset of real facility names); this
|
|
85
|
+
// REJECTS `/` (path injection) and any other separator. `..` path-traversal is
|
|
86
|
+
// rejected explicitly below (the charclass permits a lone `.`, so `..` must be
|
|
87
|
+
// caught separately).
|
|
88
|
+
const NAME_RE = /^[A-Za-z0-9 &.\-]+$/;
|
|
89
|
+
const NAME_MAX = 100;
|
|
90
|
+
|
|
91
|
+
const LIMIT_MIN = 1;
|
|
92
|
+
const LIMIT_MAX = 100;
|
|
93
|
+
const LIMIT_DEFAULT = 25;
|
|
94
|
+
|
|
95
|
+
// ─── Honesty notes (ADR-0059 required set) ────────────────────────
|
|
96
|
+
const CLOSED_NOTE =
|
|
97
|
+
"`closed` is the normalized EPA fac_closed_ind ('0'/'N' → false = active, '1'/'Y' → true = closed); an unrecognized value is null (unknown), NEVER a fabricated false. A closed facility no longer actively reports to TRI.";
|
|
98
|
+
const NOMINAL_NOTE =
|
|
99
|
+
"These are EPA TRI (Toxics Release Inventory) REPORTING facilities for the geography — a nominal environmental-footprint screen, NOT a compliance, enforcement, or violation determination. Use ECHO / enforcement sources for compliance status.";
|
|
100
|
+
const COUNT_FALLBACK_NOTE =
|
|
101
|
+
"The count sub-query (…/count/JSON) failed or did not report TOTALQUERYRESULTS, so totalAvailable is null (unknown) — it was NOT faked from the returned row count. hasMore is a heuristic (a full page ⇒ likely more); re-page with offset to confirm.";
|
|
102
|
+
|
|
103
|
+
// ─── Curated facility shape ───────────────────────────────────────
|
|
104
|
+
export type TriFacility = {
|
|
105
|
+
triFacilityId: string | null;
|
|
106
|
+
facilityName: string | null;
|
|
107
|
+
streetAddress: string | null;
|
|
108
|
+
city: string | null;
|
|
109
|
+
county: string | null;
|
|
110
|
+
state: string | null;
|
|
111
|
+
zip: string | null;
|
|
112
|
+
region: string | null;
|
|
113
|
+
closed: boolean | null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalize EPA's fac_closed_ind to a boolean. Live values are "0" (active) / "1"
|
|
118
|
+
* (closed); the schema also documents "N"/"Y". Anything else ⇒ null (unknown) —
|
|
119
|
+
* NEVER a fabricated false (P3).
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeClosed(v: unknown): boolean | null {
|
|
122
|
+
const s = str(v);
|
|
123
|
+
if (s === null) return null;
|
|
124
|
+
const t = s.toUpperCase();
|
|
125
|
+
if (t === "1" || t === "Y" || t === "YES" || t === "TRUE") return true;
|
|
126
|
+
if (t === "0" || t === "N" || t === "NO" || t === "FALSE") return false;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Map ONE efservice row → the curated facility shape (every scalar via `str`). */
|
|
131
|
+
function mapFacility(row: unknown): TriFacility {
|
|
132
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
133
|
+
return {
|
|
134
|
+
triFacilityId: str(r.tri_facility_id),
|
|
135
|
+
facilityName: str(r.facility_name),
|
|
136
|
+
streetAddress: str(r.street_address),
|
|
137
|
+
city: str(r.city_name),
|
|
138
|
+
county: str(r.county_name),
|
|
139
|
+
state: str(r.state_abbr),
|
|
140
|
+
zip: str(r.zip_code),
|
|
141
|
+
region: str(r.region),
|
|
142
|
+
closed: normalizeClosed(r.fac_closed_ind),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect:"error") ──
|
|
147
|
+
/**
|
|
148
|
+
* GET one efservice JSON resource at a MODULE-BUILT path (all user values already
|
|
149
|
+
* charclass-validated + encodeURIComponent-encoded by the caller). Builds
|
|
150
|
+
* `https://${EPA_HOST}${path}` on the FIXED host, asserts the CONSTRUCTED URL's
|
|
151
|
+
* hostname === the host over https (belt-and-suspenders), and sets
|
|
152
|
+
* `redirect:"error"` (an off-host 3xx must NOT be followed). Keyless — no headers.
|
|
153
|
+
*/
|
|
154
|
+
async function getEpa(path: string): Promise<unknown> {
|
|
155
|
+
const url = `https://${EPA_HOST}${path}`;
|
|
156
|
+
const built = new URL(url);
|
|
157
|
+
if (built.hostname !== EPA_HOST || built.protocol !== "https:") {
|
|
158
|
+
throw new ToolErrorCarrier({
|
|
159
|
+
kind: "invalid_input",
|
|
160
|
+
message: `Constructed EPA Envirofacts URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(EPA_HOST)} over https — refusing to fetch (SSRF safety).`,
|
|
161
|
+
retryable: false,
|
|
162
|
+
upstreamEndpoint: EPA_LABEL,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return getJson(built.toString(), { label: EPA_LABEL, redirect: "error" });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type EpaTriFacilitiesArgs = {
|
|
169
|
+
state?: string;
|
|
170
|
+
facilityName?: string;
|
|
171
|
+
county?: string;
|
|
172
|
+
limit?: number;
|
|
173
|
+
offset?: number;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Validate + encode ONE user path-segment value (facilityName / county). */
|
|
177
|
+
function validateName(value: string, field: string): string {
|
|
178
|
+
// Reject `..` (path traversal) AND a lone `.` — both are URL dot-segments that
|
|
179
|
+
// WHATWG `URL` silently COLLAPSES, which would drop the CONTAINING value from the
|
|
180
|
+
// path, slide the next literal into its place, and leave `_meta.filtersApplied`
|
|
181
|
+
// claiming a filter the wire request no longer carries (a false-filtersApplied bug).
|
|
182
|
+
if (
|
|
183
|
+
value.length > NAME_MAX ||
|
|
184
|
+
!NAME_RE.test(value) ||
|
|
185
|
+
value.includes("..") ||
|
|
186
|
+
value === "."
|
|
187
|
+
) {
|
|
188
|
+
throw new ToolErrorCarrier({
|
|
189
|
+
kind: "invalid_input",
|
|
190
|
+
retryable: false,
|
|
191
|
+
message: `Invalid ${field} ${JSON.stringify(value)} — allowed: letters, digits, space, & - . (≤${NAME_MAX} chars); '/' and '..' are rejected (it rides in the request PATH as a segment; it is strictly validated to prevent path injection).`,
|
|
192
|
+
upstreamEndpoint: EPA_LABEL,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return encodeURIComponent(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Look up EPA TRI reporting facilities by state / facilityName / county →
|
|
200
|
+
* normalized facility rows + honest `_meta`. Requires at least `state` OR
|
|
201
|
+
* `facilityName` (an all-empty query is refused). Runs a count sub-query FIRST for
|
|
202
|
+
* the EXACT total (P1), then the data slice; a count failure degrades to
|
|
203
|
+
* totalAvailable:null + a note (never a length-faked total).
|
|
204
|
+
*/
|
|
205
|
+
export async function triFacilities(
|
|
206
|
+
args: EpaTriFacilitiesArgs,
|
|
207
|
+
): Promise<MetaBundle> {
|
|
208
|
+
// ── [input guard] require at least state OR facilityName (never scan the whole
|
|
209
|
+
// national table). ──
|
|
210
|
+
const hasState = args.state !== undefined && args.state !== "";
|
|
211
|
+
const hasName = args.facilityName !== undefined && args.facilityName !== "";
|
|
212
|
+
if (!hasState && !hasName) {
|
|
213
|
+
throw new ToolErrorCarrier({
|
|
214
|
+
kind: "invalid_input",
|
|
215
|
+
retryable: false,
|
|
216
|
+
message:
|
|
217
|
+
"epa_tri_facilities requires at least `state` (2-letter, e.g. 'VA') OR `facilityName` — an all-empty query would scan the entire national TRI table and is refused. Add a filter and retry.",
|
|
218
|
+
upstreamEndpoint: EPA_LABEL,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Validate + build the filter path segments (SSRF: charclass + encode each). ──
|
|
223
|
+
const segments: string[] = ["efservice", EPA_TABLE];
|
|
224
|
+
const filtersApplied: string[] = [];
|
|
225
|
+
|
|
226
|
+
if (hasState) {
|
|
227
|
+
const state = args.state as string;
|
|
228
|
+
if (!STATE_RE.test(state)) {
|
|
229
|
+
throw new ToolErrorCarrier({
|
|
230
|
+
kind: "invalid_input",
|
|
231
|
+
retryable: false,
|
|
232
|
+
message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state code (^[A-Za-z]{2}$), e.g. "VA" (it rides in the request PATH; it is strictly validated).`,
|
|
233
|
+
upstreamEndpoint: EPA_LABEL,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
segments.push("state_abbr", encodeURIComponent(state));
|
|
237
|
+
filtersApplied.push(`state:${state}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (hasName) {
|
|
241
|
+
const enc = validateName(args.facilityName as string, "facilityName");
|
|
242
|
+
segments.push("facility_name", "CONTAINING", enc);
|
|
243
|
+
filtersApplied.push(`facilityName~${args.facilityName}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (args.county !== undefined && args.county !== "") {
|
|
247
|
+
const enc = validateName(args.county, "county");
|
|
248
|
+
segments.push("county_name", "CONTAINING", enc);
|
|
249
|
+
filtersApplied.push(`county~${args.county}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Coerce limit / offset to non-negative integers (belt-and-suspenders behind
|
|
253
|
+
// the server Zod; a DIRECT handler call bypasses Zod). ──
|
|
254
|
+
let limit = LIMIT_DEFAULT;
|
|
255
|
+
if (typeof args.limit === "number" && Number.isFinite(args.limit)) {
|
|
256
|
+
limit = Math.trunc(args.limit);
|
|
257
|
+
if (limit < LIMIT_MIN) limit = LIMIT_MIN;
|
|
258
|
+
if (limit > LIMIT_MAX) limit = LIMIT_MAX;
|
|
259
|
+
}
|
|
260
|
+
let offset = 0;
|
|
261
|
+
if (typeof args.offset === "number" && Number.isFinite(args.offset) && args.offset > 0) {
|
|
262
|
+
offset = Math.trunc(args.offset);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const filterPath = "/" + segments.join("/");
|
|
266
|
+
|
|
267
|
+
// ── (1) The COUNT sub-query FIRST (best-effort — the EXACT total, P1). Any
|
|
268
|
+
// failure (network/5xx/drift/missing field) degrades to totalAvailable:null +
|
|
269
|
+
// a disclosing note; it NEVER throws and NEVER fakes the total from the slice
|
|
270
|
+
// length. ──
|
|
271
|
+
let totalAvailable: number | null = null;
|
|
272
|
+
let countFailed = false;
|
|
273
|
+
try {
|
|
274
|
+
const countBody = await getEpa(`${filterPath}/count/JSON`);
|
|
275
|
+
if (
|
|
276
|
+
Array.isArray(countBody) &&
|
|
277
|
+
countBody.length > 0 &&
|
|
278
|
+
countBody[0] !== null &&
|
|
279
|
+
typeof countBody[0] === "object"
|
|
280
|
+
) {
|
|
281
|
+
const t = num((countBody[0] as Record<string, unknown>).TOTALQUERYRESULTS);
|
|
282
|
+
if (t !== null && t >= 0) {
|
|
283
|
+
totalAvailable = t;
|
|
284
|
+
} else {
|
|
285
|
+
countFailed = true; // present body but no usable TOTALQUERYRESULTS (P4)
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
countFailed = true; // count body not the expected [{...}] shape
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
countFailed = true; // any count error ⇒ degrade, never propagate (P1)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── (2) The DATA slice — the authoritative request. efservice rows are INCLUSIVE
|
|
295
|
+
// on both ends: rows/{offset}:{offset+limit-1}. Its errors follow P2. ──
|
|
296
|
+
const end = offset + limit - 1;
|
|
297
|
+
const dataPath = `${filterPath}/rows/${offset}:${end}/JSON`;
|
|
298
|
+
|
|
299
|
+
// Catch-ladder (datagov-catalog shape, VERBATIM): preserve the 4xx/5xx/timeout
|
|
300
|
+
// ToolErrorCarrier taxonomy FIRST; reclassify a 200 non-JSON SyntaxError to
|
|
301
|
+
// schema_drift SECOND; bare-rethrow LAST.
|
|
302
|
+
let body: unknown;
|
|
303
|
+
try {
|
|
304
|
+
body = await getEpa(dataPath);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
307
|
+
if (e instanceof SyntaxError)
|
|
308
|
+
throw driftError(
|
|
309
|
+
EPA_LABEL,
|
|
310
|
+
"EPA Envirofacts returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
311
|
+
);
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// [P4] the data body MUST be an array (a non-array 200 is drift, never a
|
|
316
|
+
// fabricated empty).
|
|
317
|
+
if (!Array.isArray(body)) {
|
|
318
|
+
throw driftError(
|
|
319
|
+
EPA_LABEL,
|
|
320
|
+
"EPA Envirofacts shape drift — the /rows/…/JSON response must be a JSON array of facilities.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const facilities = (body as unknown[]).map(mapFacility);
|
|
325
|
+
const returned = facilities.length;
|
|
326
|
+
|
|
327
|
+
// ── [P1] pagination. total known ⇒ hasMore = offset+returned < total; total
|
|
328
|
+
// unknown (count degraded) ⇒ a full page is the honest heuristic for "more". ──
|
|
329
|
+
const hasMore =
|
|
330
|
+
totalAvailable !== null
|
|
331
|
+
? offset + returned < totalAvailable
|
|
332
|
+
: returned === limit;
|
|
333
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
334
|
+
|
|
335
|
+
const notes: string[] = [CLOSED_NOTE, NOMINAL_NOTE];
|
|
336
|
+
if (countFailed) notes.push(COUNT_FALLBACK_NOTE);
|
|
337
|
+
|
|
338
|
+
// When the total is UNKNOWN (count sub-query degraded) AND this is not the first
|
|
339
|
+
// page, we cannot claim the response is the ENTIRE result set — rows 0..offset-1
|
|
340
|
+
// are absent and no total confirms coverage. Force truncated so buildMeta never
|
|
341
|
+
// derives complete:true on a partial last page fetched at offset>0 with null total.
|
|
342
|
+
const cannotProveComplete = totalAvailable === null && offset > 0;
|
|
343
|
+
|
|
344
|
+
const meta: Partial<ResponseMeta> = {
|
|
345
|
+
source: `${EPA_HOST} EPA Envirofacts /efservice/${EPA_TABLE} (TRI facilities; keyless)`,
|
|
346
|
+
keylessMode: true,
|
|
347
|
+
returned,
|
|
348
|
+
totalAvailable,
|
|
349
|
+
...(cannotProveComplete ? { truncated: true } : {}),
|
|
350
|
+
filtersApplied,
|
|
351
|
+
filtersDropped: [],
|
|
352
|
+
fieldsUnavailable: [],
|
|
353
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
354
|
+
notes,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return withMeta({ facilities }, meta);
|
|
358
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -208,6 +208,17 @@ export async function fetchWithRetry(
|
|
|
208
208
|
e instanceof Error &&
|
|
209
209
|
(e.name === "TimeoutError" || e.name === "AbortError")
|
|
210
210
|
) {
|
|
211
|
+
// HONESTY (dogfooding 2026-07-16): if a PRIOR attempt already classified a
|
|
212
|
+
// real upstream signal — a 429 rate_limit — do NOT mask it as a generic
|
|
213
|
+
// "timed out". The abort here is a DOWNSTREAM artifact of waiting out that
|
|
214
|
+
// rate limit (the retry-after wait outran getJson's AbortSignal, so the
|
|
215
|
+
// next fetch hits the already-aborted signal). Surfacing "timed out" hides
|
|
216
|
+
// the true cause (rate-limited) AND its remedy (wait / supply an API key)
|
|
217
|
+
// and drops the retryable+retryAfterSeconds guidance. Prefer the real
|
|
218
|
+
// rate_limited error. (A pure timeout with no prior 429 keeps "timed out".)
|
|
219
|
+
if (lastErr && lastErr.kind === "rate_limited") {
|
|
220
|
+
throw new ToolErrorCarrier(lastErr);
|
|
221
|
+
}
|
|
211
222
|
throw new ToolErrorCarrier({
|
|
212
223
|
kind: "upstream_unavailable",
|
|
213
224
|
message: `Request to ${endpointLabel} timed out.`,
|
package/src/far.ts
CHANGED
|
@@ -156,7 +156,9 @@ async function title48Currency(): Promise<{
|
|
|
156
156
|
latestAmendedOn: string | null;
|
|
157
157
|
}> {
|
|
158
158
|
return memoize("far:title48-currency", async () => {
|
|
159
|
-
|
|
159
|
+
// listTitles now returns a MetaBundle (honesty envelope); the titles live on
|
|
160
|
+
// `.data.titles`.
|
|
161
|
+
const { titles } = (await listTitles()).data;
|
|
160
162
|
const t48 = titles.find((t) => t.number === 48);
|
|
161
163
|
return {
|
|
162
164
|
upToDateAsOf: t48?.upToDateAsOf ?? null,
|
package/src/federal-register.ts
CHANGED
|
@@ -261,16 +261,35 @@ export async function listAgencies(args: { perPage?: number }) {
|
|
|
261
261
|
const json = await fetchJson<Resp>(
|
|
262
262
|
`${FED_REG}/agencies.json?per_page=${args.perPage ?? 100}`,
|
|
263
263
|
);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
264
|
+
const agencies = (json ?? []).map((a) => ({
|
|
265
|
+
id: a.id ?? 0,
|
|
266
|
+
name: a.name ?? "",
|
|
267
|
+
shortName: a.short_name,
|
|
268
|
+
slug: a.slug ?? "",
|
|
269
|
+
description: a.description ?? "",
|
|
270
|
+
parentId: a.parent_id,
|
|
271
|
+
}));
|
|
272
|
+
// Carry the honesty envelope every other tool has (dogfooding 2026-07-15: this
|
|
273
|
+
// list tool previously returned a bare {agencies} with no _meta). The
|
|
274
|
+
// agencies.json endpoint returns the COMPLETE canonical agency set in one
|
|
275
|
+
// response and IGNORES per_page upstream (live-verified 2026-07-15: per_page
|
|
276
|
+
// 10/100/1000 all return the full 472) — so the count IS the total, perPage is
|
|
277
|
+
// not honored, and complete derives true.
|
|
278
|
+
return withMeta(
|
|
279
|
+
{ agencies },
|
|
280
|
+
{
|
|
281
|
+
source: "federalregister.gov/api/v1 (agencies.json)",
|
|
282
|
+
keylessMode: true,
|
|
283
|
+
returned: agencies.length,
|
|
284
|
+
totalAvailable: agencies.length,
|
|
285
|
+
truncated: false,
|
|
286
|
+
filtersApplied: [],
|
|
287
|
+
filtersDropped: [],
|
|
288
|
+
notes: [
|
|
289
|
+
"Complete canonical list of all Federal Register agencies — the endpoint returns every agency in a single response and IGNORES the perPage argument upstream (the full set is always returned).",
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
);
|
|
274
293
|
});
|
|
275
294
|
}
|
|
276
295
|
|
package/src/fema.ts
CHANGED
|
@@ -88,6 +88,40 @@ export { num };
|
|
|
88
88
|
// ─── Fixed host + curated dataset registry (SSRF core) ────────────
|
|
89
89
|
export const FEMA_HOST = "www.fema.gov";
|
|
90
90
|
|
|
91
|
+
// The HazardMitigationAssistanceProjects dataset filters on the FULL state NAME
|
|
92
|
+
// ('Alabama'), NOT the 2-letter code the SIBLING FEMA tools (PA/declarations) use —
|
|
93
|
+
// so a caller who naturally passes 'AL' (as those tools accept) would get a
|
|
94
|
+
// confidently-wrong empty (total:0). Map a 2-letter USPS code → the canonical FEMA
|
|
95
|
+
// full name so the HMA tool accepts EITHER form; a full name (or an unknown token)
|
|
96
|
+
// passes through unchanged. (Same confidently-wrong-empty class this codebase guards
|
|
97
|
+
// elsewhere — don't ship a new tool with that foot-gun.)
|
|
98
|
+
const US_STATE_ABBR_TO_NAME: Readonly<Record<string, string>> = {
|
|
99
|
+
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California",
|
|
100
|
+
CO: "Colorado", CT: "Connecticut", DE: "Delaware", DC: "District of Columbia",
|
|
101
|
+
FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho", IL: "Illinois",
|
|
102
|
+
IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana",
|
|
103
|
+
ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota",
|
|
104
|
+
MS: "Mississippi", MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
|
105
|
+
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
|
106
|
+
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", OR: "Oregon",
|
|
107
|
+
PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota",
|
|
108
|
+
TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia",
|
|
109
|
+
WA: "Washington", WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
|
|
110
|
+
PR: "Puerto Rico", VI: "Virgin Islands", GU: "Guam", AS: "American Samoa",
|
|
111
|
+
MP: "Northern Mariana Islands",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Resolve a HMA `state` arg: a 2-letter USPS code → the FEMA full name; a full
|
|
115
|
+
* name (or any non-2-letter token) is returned unchanged. */
|
|
116
|
+
export function resolveHmaState(state: string): string {
|
|
117
|
+
const t = state.trim();
|
|
118
|
+
if (/^[A-Za-z]{2}$/.test(t)) {
|
|
119
|
+
const full = US_STATE_ABBR_TO_NAME[t.toUpperCase()];
|
|
120
|
+
if (full) return full;
|
|
121
|
+
}
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
/** A pinned dataset entry — the single source of truth for entityName + version
|
|
92
126
|
* + the per-tool $filter field whitelist + the amount fields to null-coerce. */
|
|
93
127
|
type FemaDatasetDef = {
|
|
@@ -153,6 +187,33 @@ export const FEMA_DATASETS = {
|
|
|
153
187
|
]),
|
|
154
188
|
amountFields: [],
|
|
155
189
|
},
|
|
190
|
+
// Hazard Mitigation Assistance projects — 56,034 rows (LIVE-VERIFIED 2026-07-16).
|
|
191
|
+
// The disaster-RESILIENCE grant axis (HMGP/FMA/PDM/BRIC mitigation grants to
|
|
192
|
+
// state/local/tribal subrecipients) — distinct from PA's disaster-RECOVERY spend.
|
|
193
|
+
// The planned "3rd tool" of ADR-0016 §3. THIS dataset's `state` is the FULL state
|
|
194
|
+
// NAME ('Alabama'; `stateAbbreviation` → HTTP 400). Every field below LIVE-VERIFIED
|
|
195
|
+
// to NARROW (2026-07-16): state 2457/'Alabama', programArea 42657/'HMGP',
|
|
196
|
+
// disasterNumber 330/1605, status 36123/'Closed', programFy 2780/2005, region
|
|
197
|
+
// 15922/4, projectAmount ge 1e6 → 10257.
|
|
198
|
+
hazard_mitigation: {
|
|
199
|
+
entityName: "HazardMitigationAssistanceProjects",
|
|
200
|
+
version: "v4",
|
|
201
|
+
filterFields: new Set([
|
|
202
|
+
"state",
|
|
203
|
+
"programArea",
|
|
204
|
+
"disasterNumber",
|
|
205
|
+
"status",
|
|
206
|
+
"programFy",
|
|
207
|
+
"region",
|
|
208
|
+
"projectAmount",
|
|
209
|
+
]),
|
|
210
|
+
amountFields: [
|
|
211
|
+
"projectAmount",
|
|
212
|
+
"federalShareObligated",
|
|
213
|
+
"initialObligationAmount",
|
|
214
|
+
"netValueBenefits",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
156
217
|
} satisfies Record<string, FemaDatasetDef>;
|
|
157
218
|
|
|
158
219
|
export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
|
|
@@ -539,3 +600,81 @@ export async function disasterDeclarations(args: {
|
|
|
539
600
|
const { body } = await getOpenFema("disaster_declarations", params);
|
|
540
601
|
return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
|
|
541
602
|
}
|
|
603
|
+
|
|
604
|
+
// ─── Tool 3: fema_search_hazard_mitigation ────────────────────────
|
|
605
|
+
/**
|
|
606
|
+
* Search FEMA Hazard Mitigation Assistance projects (the disaster-RESILIENCE grant
|
|
607
|
+
* axis — HMGP/FMA/PDM/BRIC mitigation grants to state/local/tribal subrecipients,
|
|
608
|
+
* distinct from Public Assistance's disaster-RECOVERY spend). Dataset
|
|
609
|
+
* HazardMitigationAssistanceProjects v4. Structured filters → module-built `$filter`
|
|
610
|
+
* (each field LIVE-VERIFIED to narrow):
|
|
611
|
+
* state → state eq (FULL state NAME, e.g. "Alabama" — NOT the 2-letter code;
|
|
612
|
+
* stateAbbreviation 400s here) · programArea eq (HMGP / FMA / PDM / BRIC / LPDM /
|
|
613
|
+
* FMA-SL) · disasterNumber eq · status eq (e.g. "Closed") · programFy eq ·
|
|
614
|
+
* region eq (FEMA region number 1–10) · minProjectAmount → projectAmount ge ·
|
|
615
|
+
* maxProjectAmount → projectAmount le.
|
|
616
|
+
* Rows carry projectAmount / federalShareObligated / initialObligationAmount /
|
|
617
|
+
* netValueBenefits as number|null. Honest `_meta` (totalAvailable = exact filtered
|
|
618
|
+
* metadata.count).
|
|
619
|
+
*/
|
|
620
|
+
export async function searchHazardMitigation(args: {
|
|
621
|
+
state?: string;
|
|
622
|
+
programArea?: string;
|
|
623
|
+
disasterNumber?: number;
|
|
624
|
+
status?: string;
|
|
625
|
+
programFy?: number;
|
|
626
|
+
region?: number;
|
|
627
|
+
minProjectAmount?: number;
|
|
628
|
+
maxProjectAmount?: number;
|
|
629
|
+
limit?: number;
|
|
630
|
+
offset?: number;
|
|
631
|
+
}): Promise<MetaBundle> {
|
|
632
|
+
const limit = args.limit ?? 100;
|
|
633
|
+
const offset = args.offset ?? 0;
|
|
634
|
+
|
|
635
|
+
const clauses: FilterClause[] = [];
|
|
636
|
+
const filtersApplied: string[] = [];
|
|
637
|
+
if (args.state !== undefined) {
|
|
638
|
+
// Accept a 2-letter code (as the sibling FEMA tools do) OR a full name — HMA's
|
|
639
|
+
// upstream filters on the FULL name, so a bare 'AL' would silently return 0.
|
|
640
|
+
clauses.push({ field: "state", op: "eq", type: "string", value: resolveHmaState(args.state) });
|
|
641
|
+
filtersApplied.push("state");
|
|
642
|
+
}
|
|
643
|
+
if (args.programArea !== undefined) {
|
|
644
|
+
clauses.push({ field: "programArea", op: "eq", type: "string", value: args.programArea });
|
|
645
|
+
filtersApplied.push("programArea");
|
|
646
|
+
}
|
|
647
|
+
if (args.disasterNumber !== undefined) {
|
|
648
|
+
clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
|
|
649
|
+
filtersApplied.push("disasterNumber");
|
|
650
|
+
}
|
|
651
|
+
if (args.status !== undefined) {
|
|
652
|
+
clauses.push({ field: "status", op: "eq", type: "string", value: args.status });
|
|
653
|
+
filtersApplied.push("status");
|
|
654
|
+
}
|
|
655
|
+
if (args.programFy !== undefined) {
|
|
656
|
+
clauses.push({ field: "programFy", op: "eq", type: "number", value: args.programFy });
|
|
657
|
+
filtersApplied.push("programFy");
|
|
658
|
+
}
|
|
659
|
+
if (args.region !== undefined) {
|
|
660
|
+
clauses.push({ field: "region", op: "eq", type: "number", value: args.region });
|
|
661
|
+
filtersApplied.push("region");
|
|
662
|
+
}
|
|
663
|
+
if (args.minProjectAmount !== undefined) {
|
|
664
|
+
clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
|
|
665
|
+
filtersApplied.push("minProjectAmount");
|
|
666
|
+
}
|
|
667
|
+
if (args.maxProjectAmount !== undefined) {
|
|
668
|
+
clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
|
|
669
|
+
filtersApplied.push("maxProjectAmount");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const params = new URLSearchParams();
|
|
673
|
+
params.set("$top", String(limit));
|
|
674
|
+
params.set("$skip", String(offset));
|
|
675
|
+
const filter = buildFilter("hazard_mitigation", clauses);
|
|
676
|
+
if (filter) params.set("$filter", filter);
|
|
677
|
+
|
|
678
|
+
const { body } = await getOpenFema("hazard_mitigation", params);
|
|
679
|
+
return shapeResponse({ body, datasetKey: "hazard_mitigation", offset, limit, filtersApplied });
|
|
680
|
+
}
|
package/src/fred.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* FRED has NO keyless tier: every request needs `&api_key=`, and a missing/bad
|
|
9
9
|
* key returns HTTP 400 `{error_code, error_message}`. So, honestly: with NO
|
|
10
10
|
* `FRED_API_KEY` these two tools THROW an `invalid_input` config error BEFORE any
|
|
11
|
-
* fetch (never a fake-empty, never a keyless-pretend). The other
|
|
11
|
+
* fetch (never a fake-empty, never a keyless-pretend). The other tools stay
|
|
12
12
|
* keyless — this key is scoped to this one source. (Contrast the OPTIONAL keys of
|
|
13
13
|
* datagov/bls/nvd, which lift a tier but are not required.)
|
|
14
14
|
*
|