@cliwant/mcp-sam-gov 0.2.1 → 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 +279 -67
  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 +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  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 +92 -17
  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 +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  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 +432 -15
  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 +114 -11
  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 +3897 -295
  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 +1664 -228
  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 +73 -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 +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  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 +706 -191
  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 +290 -155
  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 +4559 -327
  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 +2680 -925
  228. package/src/usitc.ts +420 -0
package/src/fema.ts ADDED
@@ -0,0 +1,541 @@
1
+ /**
2
+ * OpenFEMA — keyless federal disaster declarations + emergency-assistance spend
3
+ * (ADR-0016). A NEW DOMAIN: federal disaster declarations + FEMA Public Assistance
4
+ * grants to state/local/tribal applicants (the SLED emergency-spend / B2G-adjacent
5
+ * angle). Fully PUBLIC, KEYLESS (no key, no token, no header — OpenFEMA is
6
+ * unauthenticated). Nth consumer of the fetch/map/meta shape after treasury /
7
+ * edgar / socrata / ckan.
8
+ * Row query: https://www.fema.gov/api/open/{version}/{EntityName}?{OData params}
9
+ *
10
+ * REUSE (writes ZERO fetch/coercion/error code): `getJson`+`redirect:"error"`
11
+ * (datasource.ts), `num`/`str` (coerce.ts), `driftError` (datasource.ts),
12
+ * `withMeta`/`buildMeta` (meta.ts), the errors.ts taxonomy. COPIES (does NOT
13
+ * import) the socrata/ckan fixed-host SSRF + honesty PATTERN. NEW (small): the
14
+ * OData `$filter` builder (structured args → escaped filter string) + the
15
+ * `FEMA_DATASETS` registry (entityName+version+per-tool field-whitelist pins).
16
+ *
17
+ * ★ THE TOTAL-HONESTY CRUX — `metadata.count` (`$inlinecount=allpages`).
18
+ * OpenFEMA reports the exact FILTERED total in `metadata.count` — a real JSON
19
+ * NUMBER — but ONLY when the query carries `$inlinecount=allpages`. WITHOUT it,
20
+ * `metadata.count` is `0` — a SENTINEL, not the real total (live-verified
21
+ * 2026-07-12: `?$top=1` → count:0; `?$top=1&$inlinecount=allpages` → count:70049).
22
+ * So `getOpenFema` ALWAYS sets `$inlinecount=allpages` (inside the fetch fn, not
23
+ * the caller — no code path can omit it) and reads `metadata.count` as
24
+ * `totalAvailable`. Reading count off a query that omitted inlinecount would
25
+ * report `totalAvailable:0` on a full page — a data-absence-as-zero lie. The count
26
+ * is EXACT (respects the filter), so `hasMore`/`complete` are exact — NO
27
+ * totalIsLowerBound / totalIsEstimated hedge.
28
+ *
29
+ * ★ Entity-keyed envelope (the shape quirk). Results live under `body[EntityName]`
30
+ * (e.g. `body.DisasterDeclarationsSummaries`), NOT a fixed `results`/`data`. The
31
+ * dataset registry carries the entity name, known ahead of the fetch. `metadata`
32
+ * carries the exact filtered `count`.
33
+ *
34
+ * ★ SSRF guard (policy① — mirror socrata.ts / ckan.ts fixed-host). Fixed host
35
+ * `www.fema.gov` + a CURATED dataset registry pinning `{entityName, version}` (no
36
+ * free host, no free path, no free version — HMA's live v2→v4 drift is exactly why
37
+ * version is pinned, never a caller param):
38
+ * 1. `datasetKey` ∈ the frozen `FEMA_DATASETS` registry (belt-and-suspenders
39
+ * recheck in the fetch fn → invalid_input before any fetch);
40
+ * 2. construct `https://www.fema.gov/api/open/${version}/${entityName}?${params}`
41
+ * (host + path segments come ONLY from the pinned registry entry — nothing
42
+ * caller-supplied interpolates into the path), then ASSERT the built URL's
43
+ * hostname === "www.fema.gov" and protocol === "https:" (else invalid_input);
44
+ * 3. OData params via URLSearchParams — `$inlinecount`/`$top`/`$skip` set by the
45
+ * module; `$filter` is MODULE-BUILT from structured args against a per-tool
46
+ * field whitelist (NO raw caller `$filter` — no tool exposes one). String
47
+ * values escape a single-quote by DOUBLING it (`'`→`''`, e.g. `O'Brien` →
48
+ * `'O''Brien'`) then wrap in `'...'`; numbers are unquoted; booleans render
49
+ * `true`/`false`. An un-whitelisted field is rejected (invalid_input) — zero
50
+ * filter-injection surface AND guaranteed-valid, live-verified field names;
51
+ * 4. `redirect:"error"` on every fetch — a direct-JSON endpoint 3xx is anomalous
52
+ * → fail closed, never follow off-host.
53
+ *
54
+ * ★ Silent-filter trap AVOIDED (unlike ECHO). A bad `$filter`/`$orderby` field is
55
+ * NOT silently ignored — OpenFEMA returns HTTP 400 (live-verified). We never
56
+ * present unfiltered-as-filtered. We still module-build `$filter` from a per-tool
57
+ * whitelist (belt-and-suspenders: no 400 in normal use + no injection surface).
58
+ *
59
+ * ★ 200-HTML maintenance-page guard (ADR-0016 OQ1). FEMA's Drupal can serve an
60
+ * "experiencing technical difficulties" HTML page. Observed failures were HTTP 404
61
+ * (→ not_found, thrown before r.json()); but IF an outage returns that page at
62
+ * HTTP 200, getJson's r.json() throws a SyntaxError. `getOpenFema` catches it →
63
+ * `driftError` (never a fake-empty, and a clean classification). Shared getJson
64
+ * stays byte-identical (the shape-drift check stays at the call site).
65
+ *
66
+ * ★ PER-DATASET FIELD NAMES DIFFER — live-verified (ADR-0016 M1). `state eq 'CA'`
67
+ * works on DisasterDeclarationsSummaries (1689) but `stateAbbreviation` → HTTP 400
68
+ * there; conversely `stateAbbreviation eq 'LA'` works on
69
+ * PublicAssistanceFundedProjectsDetails (39444) but `state` → HTTP 400 there. Each
70
+ * tool maps its user-facing `state` arg to ITS dataset's real OData field. Every
71
+ * shipped filter below NARROWS its dataset (live-verified 2026-07-12); a field
72
+ * that 400s or is silently ignored is NOT shipped.
73
+ *
74
+ * HMA v4 (HazardMitigationAssistanceProjects) is a 3rd-tool backlog item (per
75
+ * ADR-0016 §3) — slice 1 ships the 2 core tools.
76
+ */
77
+
78
+ import { ToolErrorCarrier } from "./errors.js";
79
+ import { getJson, driftError } from "./datasource.js";
80
+ import { num } from "./coerce.js";
81
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
82
+
83
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
84
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
85
+ // `num` from this module (fema.num === coerce.num === socrata.num === ckan.num).
86
+ export { num };
87
+
88
+ // ─── Fixed host + curated dataset registry (SSRF core) ────────────
89
+ export const FEMA_HOST = "www.fema.gov";
90
+
91
+ /** A pinned dataset entry — the single source of truth for entityName + version
92
+ * + the per-tool $filter field whitelist + the amount fields to null-coerce. */
93
+ type FemaDatasetDef = {
94
+ /** The OData EntityName — ALSO the envelope's results key (`body[entityName]`). */
95
+ entityName: string;
96
+ /** The per-dataset API version — PINNED, never a caller param (HMA drift). */
97
+ version: string;
98
+ /** The live-verified $filter field whitelist for THIS dataset (each NARROWS;
99
+ * a bad field → HTTP 400 — see ADR-0016 M1). Belt-and-suspenders: the builder
100
+ * rejects any field not in this set (invalid_input). */
101
+ filterFields: ReadonlySet<string>;
102
+ /** Amount fields to run through `num` (null-never-0): a real 0 stays 0, absent/
103
+ * ""/"null" → null. Empty for datasets with no money columns. */
104
+ amountFields: readonly string[];
105
+ };
106
+
107
+ /**
108
+ * The frozen dataset registry (SSRF core — no free host, no free path, no free
109
+ * version). Each entry's count + fields are LIVE-VERIFIED keyless (2026-07-12).
110
+ * Adding a dataset later = a registry edit + a live
111
+ * `$top=1&$inlinecount=allpages` verify (entity key, version, count) + a test
112
+ * note — NEVER a free runtime param. Re-verify periodically against
113
+ * `/api/open/v1/DataSets` (versions drift: HMA already moved v2→v4).
114
+ */
115
+ export const FEMA_DATASETS = {
116
+ // SLED emergency spend — 803,904 rows (2026-07-12). stateAbbreviation is THIS
117
+ // dataset's state field (`state` → HTTP 400 here). NO `applicantName` field
118
+ // exists (only applicantId + applicationTitle).
119
+ public_assistance: {
120
+ entityName: "PublicAssistanceFundedProjectsDetails",
121
+ version: "v2",
122
+ filterFields: new Set([
123
+ "stateAbbreviation", // state → 39444 for 'LA'
124
+ "disasterNumber", // → 15 for 3638
125
+ "applicantId", // → 8 for '015-UF5E0-00'
126
+ "damageCategoryCode", // → 232398 for 'B'
127
+ "incidentType", // → 80407 for 'Flood'
128
+ "projectAmount", // ge 1e6 → 25284; le 10000 → 354953
129
+ "declarationDate", // ge '2024-01-01' → 30082
130
+ ]),
131
+ amountFields: [
132
+ "projectAmount",
133
+ "federalShareObligated",
134
+ "totalObligated",
135
+ "mitigationAmount",
136
+ ],
137
+ },
138
+ // Declared disasters — 70,049 rows (2026-07-12). `state` is THIS dataset's state
139
+ // field (`stateAbbreviation` → HTTP 400 here). Program flags are BOOLEAN
140
+ // (`eq true`; `eq 1` → HTTP 400).
141
+ disaster_declarations: {
142
+ entityName: "DisasterDeclarationsSummaries",
143
+ version: "v2",
144
+ filterFields: new Set([
145
+ "state", // → 1689 for 'CA'
146
+ "incidentType", // → 11346 for 'Flood'
147
+ "declarationType", // DR 46462 / EM 21471 / FM 2116
148
+ "fyDeclared", // → 2147 for 2024
149
+ "disasterNumber", // → 64 for 4611
150
+ "declarationDate", // ge '2024-01-01' → 5067
151
+ "paProgramDeclared", // eq true → 65534
152
+ "iaProgramDeclared", // eq true → 17187
153
+ ]),
154
+ amountFields: [],
155
+ },
156
+ } satisfies Record<string, FemaDatasetDef>;
157
+
158
+ export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
159
+
160
+ const SOURCE_LABEL = "openfema:" + FEMA_HOST;
161
+
162
+ // ─── OData $filter builder (module-built; per-tool whitelist; escaped) ──
163
+ type FilterOp = "eq" | "ge" | "le";
164
+ export type FilterClause =
165
+ | { field: string; op: FilterOp; type: "string"; value: string }
166
+ | { field: string; op: FilterOp; type: "number"; value: number }
167
+ | { field: string; op: FilterOp; type: "boolean"; value: boolean };
168
+
169
+ /** OData string-literal escaping: double every single-quote (`'` → `''`). This is
170
+ * the ONLY escaping OData needs for a `'...'` literal — zero injection surface. */
171
+ export function escapeODataString(v: string): string {
172
+ return v.replace(/'/g, "''");
173
+ }
174
+
175
+ /** Render ONE clause's value: strings quoted+escaped, numbers bare, booleans
176
+ * `true`/`false`. */
177
+ function renderValue(c: FilterClause): string {
178
+ switch (c.type) {
179
+ case "string":
180
+ return `'${escapeODataString(c.value)}'`;
181
+ case "number":
182
+ return String(c.value);
183
+ case "boolean":
184
+ return c.value ? "true" : "false";
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Build the `$filter` string from structured clauses against the dataset's
190
+ * whitelist. Each clause's field MUST be in `FEMA_DATASETS[datasetKey].filterFields`
191
+ * (belt-and-suspenders — the tool functions only ever pass whitelisted fields, but
192
+ * an un-whitelisted field is a hard invalid_input, never silently dropped). Returns
193
+ * null when there are no clauses (⇒ no `$filter` param).
194
+ */
195
+ export function buildFilter(
196
+ datasetKey: FemaDatasetKey,
197
+ clauses: FilterClause[],
198
+ ): string | null {
199
+ if (clauses.length === 0) return null;
200
+ const allowed = FEMA_DATASETS[datasetKey].filterFields;
201
+ return clauses
202
+ .map((c) => {
203
+ if (!allowed.has(c.field)) {
204
+ throw new ToolErrorCarrier({
205
+ kind: "invalid_input",
206
+ message: `OpenFEMA $filter field ${JSON.stringify(c.field)} is not in the ${datasetKey} whitelist — refusing to build an un-whitelisted filter (SSRF/injection safety).`,
207
+ retryable: false,
208
+ });
209
+ }
210
+ return `${c.field} ${c.op} ${renderValue(c)}`;
211
+ })
212
+ .join(" and ");
213
+ }
214
+
215
+ // ─── fetch layer ──────────────────────────────────────────────────
216
+ export type FemaRow = Record<string, unknown>;
217
+
218
+ /**
219
+ * GET one OpenFEMA dataset page. SSRF guard (belt-and-suspenders behind the pinned
220
+ * registry): datasetKey ∈ registry, and the CONSTRUCTED URL's hostname ===
221
+ * www.fema.gov (https). ALWAYS sets `$inlinecount=allpages` (the total-honesty
222
+ * crux — no code path can omit it) + `redirect:"error"` (B1); NO headers (keyless).
223
+ * Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited; 5xx →
224
+ * upstream_unavailable; 404 → not_found; 400 → invalid_input). A 200-non-JSON
225
+ * (Drupal maintenance page) → SyntaxError → driftError (ADR-0016 OQ1). Returns the
226
+ * parsed body (unknown; the caller validates the entity-keyed shape).
227
+ */
228
+ async function getOpenFema(
229
+ datasetKey: FemaDatasetKey,
230
+ params: URLSearchParams,
231
+ ): Promise<{ body: unknown; entityName: string }> {
232
+ const def = FEMA_DATASETS[datasetKey];
233
+ if (!def) {
234
+ throw new ToolErrorCarrier({
235
+ kind: "invalid_input",
236
+ message: `OpenFEMA dataset ${JSON.stringify(datasetKey)} is not in the curated registry. Allowed: ${Object.keys(FEMA_DATASETS).join(", ")}.`,
237
+ retryable: false,
238
+ });
239
+ }
240
+ // ★ ALWAYS send $inlinecount=allpages — WITHOUT it metadata.count is a 0
241
+ // sentinel, not the real total (a data-absence-as-zero lie). Set HERE (not the
242
+ // caller) so no code path can omit it. Dropping this line turns a fault test RED.
243
+ params.set("$inlinecount", "allpages");
244
+
245
+ const url = `https://${FEMA_HOST}/api/open/${def.version}/${def.entityName}?${params.toString()}`;
246
+ // Belt-and-suspenders: host + path come ONLY from the pinned registry entry;
247
+ // assert the built URL cannot have been steered off-host.
248
+ const built = new URL(url);
249
+ if (built.hostname !== FEMA_HOST || built.protocol !== "https:") {
250
+ throw new ToolErrorCarrier({
251
+ kind: "invalid_input",
252
+ message: `Constructed OpenFEMA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FEMA_HOST} over https — refusing to fetch (SSRF safety).`,
253
+ retryable: false,
254
+ });
255
+ }
256
+
257
+ let body: unknown;
258
+ try {
259
+ // Keyless ⇒ NO headers key (byte-clean init); B1 redirect:"error"; host-only
260
+ // label → ToolError.upstreamEndpoint.
261
+ body = await getJson(url, { label: SOURCE_LABEL, redirect: "error" });
262
+ } catch (e) {
263
+ // Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
264
+ if (e instanceof ToolErrorCarrier) throw e;
265
+ // ★ OQ1 — a 200 non-JSON body (Drupal "technical difficulties" HTML) makes
266
+ // getJson's r.json() throw a SyntaxError. Classify it as schema_drift (an
267
+ // honest THROW + a clean classification), never a fake-empty. Shared getJson
268
+ // stays byte-identical (the check is at THIS call site).
269
+ if (e instanceof SyntaxError) {
270
+ throw driftError(
271
+ SOURCE_LABEL,
272
+ "OpenFEMA returned non-JSON at HTTP 200 — possible maintenance page",
273
+ );
274
+ }
275
+ throw e;
276
+ }
277
+ return { body, entityName: def.entityName };
278
+ }
279
+
280
+ // ─── map + meta ───────────────────────────────────────────────────
281
+ const SHAPE_NOTE =
282
+ "OpenFEMA OData envelope: rows live under the entity key (e.g. body.DisasterDeclarationsSummaries) and totalAvailable is the EXACT filtered total from metadata.count (we always send $inlinecount=allpages). Never inferred from the page length.";
283
+ const AMOUNT_NOTE =
284
+ "Amount fields (projectAmount / federalShareObligated / totalObligated / mitigationAmount) are number|null — a real 0 stays 0; absent/empty is null (never 0).";
285
+ const DATE_NOTE =
286
+ "Date filters use OData ISO compares on declarationDate; a bare 'YYYY-MM-DD' means midnight-UTC start of that day (pass a full ISO datetime for finer bounds; a 'to' bound is exclusive of intra-day times after midnight).";
287
+
288
+ /**
289
+ * Coerce the declared amount fields through `num` (null-never-0): a real 0 stays
290
+ * 0; absent/""/"null" → null. Every declared amount field is always present as
291
+ * number|null in the output (honest "unknown", never a fabricated 0). Datasets
292
+ * with no amount fields pass rows through verbatim.
293
+ */
294
+ function coerceAmounts(
295
+ rows: FemaRow[],
296
+ amountFields: readonly string[],
297
+ ): FemaRow[] {
298
+ if (amountFields.length === 0) return rows;
299
+ return rows.map((row) => {
300
+ const out: FemaRow = { ...row };
301
+ for (const f of amountFields) out[f] = num(out[f]);
302
+ return out;
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Validate the entity-keyed envelope + build the honest `_meta`. PRIMARY response:
308
+ * - `body[entityName]` MUST be an array → else driftError (nothing valid to return).
309
+ * - `metadata.count` MUST be a number (with $inlinecount it is the exact filtered
310
+ * total) → else driftError. `totalAvailable = num(metadata.count)` — the EXACT
311
+ * total, NEVER the page length (a wide page byte-truncates below $top).
312
+ * - pagination: offset=$skip, limit=$top, hasMore = offset+returned <
313
+ * totalAvailable (exact — no hedge), nextOffset. genuine-empty (count:0, []) →
314
+ * complete:true/0 (via buildMeta). outage/5xx/timeout/400/404 already THREW in
315
+ * getOpenFema (never a fake empty).
316
+ */
317
+ function shapeResponse(args: {
318
+ body: unknown;
319
+ datasetKey: FemaDatasetKey;
320
+ offset: number;
321
+ limit: number;
322
+ filtersApplied: string[];
323
+ }): MetaBundle {
324
+ const def = FEMA_DATASETS[args.datasetKey];
325
+ const b = (args.body ?? {}) as {
326
+ metadata?: { count?: unknown };
327
+ } & Record<string, unknown>;
328
+
329
+ const rawRows = b[def.entityName];
330
+ if (!Array.isArray(rawRows)) {
331
+ throw driftError(
332
+ SOURCE_LABEL,
333
+ `OpenFEMA ${def.entityName} returned an unexpected shape (body[${JSON.stringify(def.entityName)}] must be an array of rows).`,
334
+ );
335
+ }
336
+ // ★ The single most important honesty line: totalAvailable is the EXACT filtered
337
+ // metadata.count, NEVER rows.length. typeof-check BEFORE num() (num cannot tell a
338
+ // non-number from an absent one). Mutating this to rows.length must turn a test RED.
339
+ if (typeof b.metadata?.count !== "number") {
340
+ throw driftError(
341
+ SOURCE_LABEL,
342
+ `OpenFEMA ${def.entityName} returned a non-number metadata.count — with $inlinecount=allpages it must be the exact filtered total; treating as schema drift.`,
343
+ );
344
+ }
345
+ const totalAvailable = num(b.metadata.count);
346
+
347
+ const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
348
+ const returned = rows.length;
349
+ const hasMore =
350
+ totalAvailable !== null && args.offset + returned < totalAvailable;
351
+ const nextOffset = hasMore ? args.offset + returned : null;
352
+
353
+ const notes: string[] = [SHAPE_NOTE];
354
+ if (def.amountFields.length > 0) notes.push(AMOUNT_NOTE);
355
+ if (args.filtersApplied.some((f) => /date/i.test(f))) notes.push(DATE_NOTE);
356
+ // Byte-cap disclosure: a wide page can byte-truncate below $top while more rows
357
+ // remain — metadata.count is authoritative, so page via $skip.
358
+ if (returned < args.limit && hasMore) {
359
+ notes.push(
360
+ "This page returned fewer rows than the requested limit while more remain (OpenFEMA byte-truncates a wide page below $top); metadata.count is authoritative — page with a larger offset ($skip).",
361
+ );
362
+ }
363
+ // Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
364
+ if (args.offset > 100000) {
365
+ notes.push(
366
+ "Deep offset (>100000): very deep $skip into a large dataset may degrade upstream; prefer narrowing the filter over paging deep.",
367
+ );
368
+ }
369
+
370
+ return withMeta(
371
+ {
372
+ dataset: def.entityName,
373
+ rows,
374
+ },
375
+ {
376
+ source: `OpenFEMA ${def.entityName} (keyless)`,
377
+ keylessMode: true,
378
+ returned,
379
+ totalAvailable,
380
+ filtersApplied: args.filtersApplied,
381
+ filtersDropped: [],
382
+ fieldsUnavailable: [],
383
+ pagination: { offset: args.offset, limit: args.limit, hasMore, nextOffset },
384
+ notes,
385
+ } satisfies Partial<ResponseMeta>,
386
+ );
387
+ }
388
+
389
+ // ─── Tool 1: fema_search_public_assistance ────────────────────────
390
+ /**
391
+ * Search FEMA Public Assistance funded projects (SLED emergency spend to state /
392
+ * local / tribal applicants). Dataset PublicAssistanceFundedProjectsDetails v2.
393
+ * Structured filters → module-built `$filter` (each field LIVE-VERIFIED to narrow):
394
+ * state → stateAbbreviation eq · disasterNumber eq · applicantId eq ·
395
+ * damageCategoryCode eq (e.g. "B" = Emergency Protective Measures) ·
396
+ * incidentType eq · minProjectAmount → projectAmount ge · maxProjectAmount →
397
+ * projectAmount le · declaredDateFrom/To → declarationDate ge/le.
398
+ * Rows carry projectAmount / federalShareObligated / totalObligated /
399
+ * mitigationAmount as number|null. Honest `_meta` (totalAvailable = exact filtered
400
+ * metadata.count).
401
+ */
402
+ export async function searchPublicAssistance(args: {
403
+ state?: string;
404
+ disasterNumber?: number;
405
+ applicantId?: string;
406
+ damageCategoryCode?: string;
407
+ incidentType?: string;
408
+ minProjectAmount?: number;
409
+ maxProjectAmount?: number;
410
+ declaredDateFrom?: string;
411
+ declaredDateTo?: string;
412
+ limit?: number;
413
+ offset?: number;
414
+ }): Promise<MetaBundle> {
415
+ const limit = args.limit ?? 100;
416
+ const offset = args.offset ?? 0;
417
+
418
+ const clauses: FilterClause[] = [];
419
+ const filtersApplied: string[] = [];
420
+ if (args.state !== undefined) {
421
+ clauses.push({ field: "stateAbbreviation", op: "eq", type: "string", value: args.state });
422
+ filtersApplied.push("state");
423
+ }
424
+ if (args.disasterNumber !== undefined) {
425
+ clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
426
+ filtersApplied.push("disasterNumber");
427
+ }
428
+ if (args.applicantId !== undefined) {
429
+ clauses.push({ field: "applicantId", op: "eq", type: "string", value: args.applicantId });
430
+ filtersApplied.push("applicantId");
431
+ }
432
+ if (args.damageCategoryCode !== undefined) {
433
+ clauses.push({ field: "damageCategoryCode", op: "eq", type: "string", value: args.damageCategoryCode });
434
+ filtersApplied.push("damageCategoryCode");
435
+ }
436
+ if (args.incidentType !== undefined) {
437
+ clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
438
+ filtersApplied.push("incidentType");
439
+ }
440
+ if (args.minProjectAmount !== undefined) {
441
+ clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
442
+ filtersApplied.push("minProjectAmount");
443
+ }
444
+ if (args.maxProjectAmount !== undefined) {
445
+ clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
446
+ filtersApplied.push("maxProjectAmount");
447
+ }
448
+ if (args.declaredDateFrom !== undefined) {
449
+ clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
450
+ filtersApplied.push("declaredDateFrom");
451
+ }
452
+ if (args.declaredDateTo !== undefined) {
453
+ clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
454
+ filtersApplied.push("declaredDateTo");
455
+ }
456
+
457
+ const params = new URLSearchParams();
458
+ params.set("$top", String(limit));
459
+ params.set("$skip", String(offset));
460
+ const filter = buildFilter("public_assistance", clauses);
461
+ if (filter) params.set("$filter", filter);
462
+
463
+ const { body } = await getOpenFema("public_assistance", params);
464
+ return shapeResponse({ body, datasetKey: "public_assistance", offset, limit, filtersApplied });
465
+ }
466
+
467
+ // ─── Tool 2: fema_disaster_declarations ───────────────────────────
468
+ /**
469
+ * Look up FEMA disaster / emergency declarations. Dataset
470
+ * DisasterDeclarationsSummaries v2. Structured filters → module-built `$filter`
471
+ * (each field LIVE-VERIFIED to narrow):
472
+ * state → state eq · incidentType eq (e.g. "Flood") · declarationType eq
473
+ * (DR/EM/FM) · fyDeclared eq · disasterNumber eq · declaredDateFrom/To →
474
+ * declarationDate ge/le · paProgramDeclared / iaProgramDeclared → eq true/false
475
+ * (BOOLEAN — `eq 1` 400s upstream).
476
+ * Honest `_meta` (totalAvailable = exact filtered metadata.count).
477
+ */
478
+ export async function disasterDeclarations(args: {
479
+ state?: string;
480
+ incidentType?: string;
481
+ declarationType?: string;
482
+ fyDeclared?: number;
483
+ disasterNumber?: number;
484
+ declaredDateFrom?: string;
485
+ declaredDateTo?: string;
486
+ paProgramDeclared?: boolean;
487
+ iaProgramDeclared?: boolean;
488
+ limit?: number;
489
+ offset?: number;
490
+ }): Promise<MetaBundle> {
491
+ const limit = args.limit ?? 100;
492
+ const offset = args.offset ?? 0;
493
+
494
+ const clauses: FilterClause[] = [];
495
+ const filtersApplied: string[] = [];
496
+ if (args.state !== undefined) {
497
+ clauses.push({ field: "state", op: "eq", type: "string", value: args.state });
498
+ filtersApplied.push("state");
499
+ }
500
+ if (args.incidentType !== undefined) {
501
+ clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
502
+ filtersApplied.push("incidentType");
503
+ }
504
+ if (args.declarationType !== undefined) {
505
+ clauses.push({ field: "declarationType", op: "eq", type: "string", value: args.declarationType });
506
+ filtersApplied.push("declarationType");
507
+ }
508
+ if (args.fyDeclared !== undefined) {
509
+ clauses.push({ field: "fyDeclared", op: "eq", type: "number", value: args.fyDeclared });
510
+ filtersApplied.push("fyDeclared");
511
+ }
512
+ if (args.disasterNumber !== undefined) {
513
+ clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
514
+ filtersApplied.push("disasterNumber");
515
+ }
516
+ if (args.declaredDateFrom !== undefined) {
517
+ clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
518
+ filtersApplied.push("declaredDateFrom");
519
+ }
520
+ if (args.declaredDateTo !== undefined) {
521
+ clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
522
+ filtersApplied.push("declaredDateTo");
523
+ }
524
+ if (args.paProgramDeclared !== undefined) {
525
+ clauses.push({ field: "paProgramDeclared", op: "eq", type: "boolean", value: args.paProgramDeclared });
526
+ filtersApplied.push("paProgramDeclared");
527
+ }
528
+ if (args.iaProgramDeclared !== undefined) {
529
+ clauses.push({ field: "iaProgramDeclared", op: "eq", type: "boolean", value: args.iaProgramDeclared });
530
+ filtersApplied.push("iaProgramDeclared");
531
+ }
532
+
533
+ const params = new URLSearchParams();
534
+ params.set("$top", String(limit));
535
+ params.set("$skip", String(offset));
536
+ const filter = buildFilter("disaster_declarations", clauses);
537
+ if (filter) params.set("$filter", filter);
538
+
539
+ const { body } = await getOpenFema("disaster_declarations", params);
540
+ return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
541
+ }