@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
@@ -17,8 +17,10 @@
17
17
  * Rate-limit: documented as ~1000 req/hour per IP (informal).
18
18
  */
19
19
 
20
- import { fetchWithRetry } from "./errors.js";
20
+ import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
21
21
  import { memoize } from "./cache.js";
22
+ import { withMeta } from "./meta.js";
23
+ import { num, str } from "./coerce.js";
22
24
 
23
25
  const FED_REG = "https://www.federalregister.gov/api/v1";
24
26
 
@@ -103,9 +105,40 @@ export async function searchDocuments(args: {
103
105
  }[];
104
106
  };
105
107
  const json = await fetchJson<Resp>(url.toString());
106
- return {
107
- totalRecords: json.count ?? 0,
108
+ // The Federal Register API HARD-CAPS `count` at 10,000 (50 pages × 200) —
109
+ // LIVE-VERIFIED 2026-07-06: an empty/nonsense term → count 0, but ANY broad term
110
+ // AND the no-term "all documents ever" query both return exactly 10,000 (the FR
111
+ // has published FAR more than 10k documents since 1994). So a count of 10,000 is
112
+ // a SATURATION FLOOR ("≥10,000"), NOT an exact total — reporting it as exact
113
+ // overstates precision and understates the true count.
114
+ const FR_COUNT_CAP = 10000;
115
+ // COUNT-DRIFT GUARD (defense-in-depth). The FR API's `count` is the match total
116
+ // (a JSON NUMBER). A 200 body that LACKS `count` (or carries a non-number) is
117
+ // SCHEMA DRIFT — the old `?? 0` coerced it to 0, fabricating an honest-empty
118
+ // (totalAvailable:0, truncated:false) on a drift body (a latent P2 violation).
119
+ // THROW schema_drift instead — mirrors this file's assertInspectionEnvelope
120
+ // (C132) and the W3-2 cms/nppes/regulations siblings (which THROW on a non-number
121
+ // count). A GENUINE `count:0` (a real, finite number) is NOT drift — it flows
122
+ // through untouched as an honest empty below.
123
+ if (typeof json.count !== "number" || !Number.isFinite(json.count)) {
124
+ throw new ToolErrorCarrier({
125
+ kind: "schema_drift",
126
+ message: `Federal Register documents.json returned HTTP 200 without a numeric \`count\` (got ${JSON.stringify(json.count)}) — the match-total field is missing/malformed; refusing rather than fabricating totalAvailable:0 on a drift body.`,
127
+ retryable: false,
128
+ upstreamEndpoint: "federal-register:documents.json",
129
+ });
130
+ }
131
+ const rawCount = json.count;
132
+ const countSaturated = rawCount >= FR_COUNT_CAP;
133
+ const data = {
134
+ totalRecords: rawCount,
135
+ // true ⇒ totalRecords is a FLOOR (≥10,000), not an exact count (API cap).
136
+ totalRecordsSaturated: countSaturated,
108
137
  totalPages: json.total_pages ?? 0,
138
+ // total_pages ALSO saturates at the API cap (50) for the same broad queries —
139
+ // flag it so a consumer never estimates the true dataset size from a capped
140
+ // page count either.
141
+ totalPagesSaturated: countSaturated,
109
142
  documents: (json.results ?? []).map((d) => ({
110
143
  documentNumber: d.document_number ?? "",
111
144
  title: d.title ?? "",
@@ -122,6 +155,51 @@ export async function searchDocuments(args: {
122
155
  })),
123
156
  })),
124
157
  };
158
+
159
+ // Truthful `_meta` (spec §1.2 A5, §2.3). The Federal Register API reports a
160
+ // match total (`count`) — REAL below the 10,000 cap, a saturation FLOOR at it
161
+ // (handled below). A5: an unknown/misspelled agency slug is silently ignored by
162
+ // the API and yields zero rows that look identical to "no matching rules" —
163
+ // call that out so the AI can re-check the slug against fed_register_list_agencies
164
+ // instead of concluding no such rule exists.
165
+ const returned = data.documents.length;
166
+ const notes: string[] = [];
167
+ if (countSaturated) {
168
+ notes.push(
169
+ `The Federal Register API caps its match count at ${FR_COUNT_CAP.toLocaleString()} (and total_pages at 50) — both are FLOORS, not exact totals: this query matches AT LEAST ${FR_COUNT_CAP.toLocaleString()} documents (the true total is unknown and likely higher). totalAvailable is null (not ${FR_COUNT_CAP.toLocaleString()}); narrow with agency/type/publicationDate filters to bring the result set BELOW ${FR_COUNT_CAP.toLocaleString()} for an exact count.`,
170
+ );
171
+ }
172
+ if ((args.agencySlugs?.length ?? 0) > 0) {
173
+ notes.push(
174
+ `Filtered by agency slug(s): ${args.agencySlugs!.join(", ")}. An unknown or misspelled slug is silently ignored by the API and yields zero results indistinguishable from "no matching documents" — verify slugs via fed_register_list_agencies if the result is unexpectedly empty.`,
175
+ );
176
+ }
177
+ // D4 (no-silent-filter): populate filtersApplied from the filters actually
178
+ // FORWARDED to the API (each was appended to the request above) — never a
179
+ // hard-coded [] that lies about which facets constrained the result. Only a
180
+ // filter that was truly sent is listed (an empty agencySlugs[] forwards nothing);
181
+ // no filter here is dropped (all supplied filters are forwarded), so
182
+ // filtersDropped stays [].
183
+ const filtersApplied: string[] = [];
184
+ if (args.query) filtersApplied.push("query");
185
+ if ((args.agencySlugs?.length ?? 0) > 0) filtersApplied.push("agencySlugs");
186
+ if (args.type) filtersApplied.push("type");
187
+ if (args.publicationDateFrom) filtersApplied.push("publicationDateFrom");
188
+ if (args.publicationDateTo) filtersApplied.push("publicationDateTo");
189
+ if (args.effectiveDateFrom) filtersApplied.push("effectiveDateFrom");
190
+ return withMeta(data, {
191
+ source: "federalregister.gov/api/v1",
192
+ keylessMode: true,
193
+ returned,
194
+ // The REAL total when known; null (unknown exact) when the count saturated at
195
+ // the FR cap — never a capped number presented as the real total.
196
+ totalAvailable: countSaturated ? null : rawCount,
197
+ truncated: countSaturated ? true : returned < rawCount,
198
+ filtersApplied,
199
+ filtersDropped: [],
200
+ fieldsUnavailable: [],
201
+ notes,
202
+ });
125
203
  }
126
204
 
127
205
  export async function getDocument(documentNumber: string) {
@@ -195,3 +273,434 @@ export async function listAgencies(args: { perPage?: number }) {
195
273
  };
196
274
  });
197
275
  }
276
+
277
+ // ─────────────────────────────────────────────────────────────────────────────
278
+ // Public Inspection desk — the "filed but NOT YET published" leading indicator
279
+ // (ADR-0043). Additive: everything below is NEW; `fetchJson`/`TYPE_MAP`/the three
280
+ // existing tools above are UNTOUCHED. Documents FILED with the Office of the
281
+ // Federal Register are on PUBLIC INSPECTION hours-to-days BEFORE their official
282
+ // `publication_date` — the earliest legal signal of an upcoming rule/notice.
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+
285
+ const PI_BASE = `${FED_REG}/public-inspection-documents`;
286
+ const PI_HOST = "www.federalregister.gov";
287
+
288
+ /**
289
+ * The mandatory pre-publication caveat — rides `_meta.notes` on EVERY response.
290
+ * A public-inspection doc is FILED, not PUBLISHED: no final FR citation/page yet,
291
+ * and it may CHANGE or be WITHDRAWN before publication. Load-bearing honesty:
292
+ * presenting a PI doc as the authoritative published rule is the forbidden lie.
293
+ */
294
+ export const PRE_PUBLICATION_CAVEAT =
295
+ "These are PRE-PUBLICATION documents on PUBLIC INSPECTION — filed with the Office of the Federal Register but NOT YET published. This is a LEADING INDICATOR, not the authoritative published rule/notice: there is NO final Federal Register citation or page number yet, and the content CAN CHANGE or be WITHDRAWN before its publication_date. After the publication_date, cross-check fed_register_get_document (by document_number) for the authoritative published version.";
296
+
297
+ /** Discloses how `leadDays` is derived (null-never-0). */
298
+ export const LEADDAYS_METHOD_NOTE =
299
+ "leadDays = publication_date minus the calendar date of filed_at, in whole days (the pre-publication head-start); null when either date is missing/unparseable, a genuine same-day filing is 0, a negative value (publication before filing) is a surfaced-verbatim anomaly.";
300
+
301
+ /** Discloses the special-vs-regular distinction (never conflated). */
302
+ export const SPECIAL_REGULAR_NOTE =
303
+ "filing_type 'special' = filed OFF-CYCLE for immediate/emergency public inspection (a stronger, SOONER signal — often same/next-day publication); 'regular' = filed for the next regular business-day inspection. Surfaced verbatim; the two are not conflated.";
304
+
305
+ export type FedRegPublicInspectionMode = "current" | "date" | "search";
306
+
307
+ export type FedRegPublicInspectionInput = {
308
+ mode?: FedRegPublicInspectionMode;
309
+ date?: string;
310
+ term?: string;
311
+ type?: "RULE" | "PRORULE" | "NOTICE" | "PRESDOCU";
312
+ agency?: string;
313
+ specialOnly?: boolean;
314
+ limit?: number;
315
+ offset?: number;
316
+ };
317
+
318
+ type RawAgency = {
319
+ raw_name?: string;
320
+ name?: string;
321
+ id?: number;
322
+ url?: string;
323
+ json_url?: string;
324
+ parent_id?: number | null;
325
+ slug?: string;
326
+ };
327
+
328
+ type RawInspectionRow = {
329
+ document_number?: string;
330
+ type?: string;
331
+ title?: string;
332
+ filed_at?: string;
333
+ publication_date?: string;
334
+ filing_type?: string;
335
+ agencies?: RawAgency[];
336
+ agency_names?: string[];
337
+ docket_numbers?: string[];
338
+ html_url?: string;
339
+ pdf_url?: string;
340
+ raw_text_url?: string;
341
+ json_url?: string;
342
+ num_pages?: number;
343
+ subject_1?: string;
344
+ subject_2?: string;
345
+ subject_3?: string;
346
+ };
347
+
348
+ type InspectionEnvelope = {
349
+ count: number;
350
+ results: RawInspectionRow[];
351
+ special_filings_updated_at?: string;
352
+ regular_filings_updated_at?: string;
353
+ total_pages?: number;
354
+ next_page_url?: string;
355
+ };
356
+
357
+ const PI_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
358
+
359
+ /**
360
+ * `leadDays` = publication_date − the CALENDAR DATE of filed_at, in whole days.
361
+ * tz-immune (compares the sliced YYYY-MM-DD strings — both on FR's Eastern basis,
362
+ * so no -04:00/-05:00 DST off-by-one). NULL-NEVER-0: null when either date is
363
+ * missing/unparseable; a genuine same-day filing survives as 0; a negative
364
+ * (publication before filing — a data anomaly) is surfaced VERBATIM, never
365
+ * clamped or nulled.
366
+ */
367
+ export function computeLeadDays(
368
+ filedAt: string | null | undefined,
369
+ publicationDate: string | null | undefined,
370
+ ): number | null {
371
+ if (
372
+ filedAt === null ||
373
+ filedAt === undefined ||
374
+ publicationDate === null ||
375
+ publicationDate === undefined
376
+ ) {
377
+ return null;
378
+ }
379
+ const filedDate = String(filedAt).slice(0, 10);
380
+ const pubDate = String(publicationDate).slice(0, 10);
381
+ if (!PI_DATE_RE.test(filedDate) || !PI_DATE_RE.test(pubDate)) return null;
382
+ const fp = filedDate.split("-");
383
+ const pp = pubDate.split("-");
384
+ const ms =
385
+ Date.UTC(Number(pp[0]), Number(pp[1]) - 1, Number(pp[2])) -
386
+ Date.UTC(Number(fp[0]), Number(fp[1]) - 1, Number(fp[2]));
387
+ return Math.round(ms / 86_400_000);
388
+ }
389
+
390
+ /**
391
+ * SSRF host re-assertion (defense-in-depth; ADR-0043 §Q7). The only caller
392
+ * values that reach the wire are `date` (→ conditions[available_on]) and `term`
393
+ * (→ conditions[term]), both URLSearchParams QUERY params on the fixed host — no
394
+ * caller-controlled PATH segment. Re-assert the constructed URL is https on
395
+ * www.federalregister.gov; anything else → invalid_input (never fetched).
396
+ */
397
+ export function assertFedRegHost(urlStr: string): void {
398
+ let u: URL;
399
+ try {
400
+ u = new URL(urlStr);
401
+ } catch {
402
+ throw new ToolErrorCarrier({
403
+ kind: "invalid_input",
404
+ message: `Refusing to fetch a non-URL public-inspection target.`,
405
+ retryable: false,
406
+ upstreamEndpoint: "federal-register:public-inspection",
407
+ });
408
+ }
409
+ if (u.protocol !== "https:" || u.hostname !== PI_HOST) {
410
+ throw new ToolErrorCarrier({
411
+ kind: "invalid_input",
412
+ message: `Refusing to fetch a non-${PI_HOST} public-inspection URL (${u.protocol}//${u.hostname}).`,
413
+ retryable: false,
414
+ upstreamEndpoint: "federal-register:public-inspection",
415
+ });
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Build the wire URL per mode. ★The date rides `conditions[available_on]` (a
421
+ * QUERY param), NEVER a `/{date}.json` path segment (fact 6: that path silently
422
+ * returns a WRONG unrelated doc). `term` rides `conditions[term]`. Both via
423
+ * URLSearchParams. `new URL` + host re-assert closes host-escape/downgrade.
424
+ */
425
+ export function buildPublicInspectionUrl(
426
+ mode: FedRegPublicInspectionMode,
427
+ opts: { date?: string; term?: string } = {},
428
+ ): string {
429
+ let url: URL;
430
+ if (mode === "current") {
431
+ url = new URL(`${PI_BASE}/current.json`);
432
+ } else if (mode === "date") {
433
+ url = new URL(`${PI_BASE}.json`);
434
+ url.searchParams.set("conditions[available_on]", opts.date ?? "");
435
+ } else {
436
+ url = new URL(`${PI_BASE}.json`);
437
+ if (opts.term) url.searchParams.set("conditions[term]", opts.term);
438
+ // per_page ≫ any observed on-inspection count (live ≤95) → one page.
439
+ url.searchParams.set("per_page", "1000");
440
+ }
441
+ const built = url.toString();
442
+ assertFedRegHost(built);
443
+ return built;
444
+ }
445
+
446
+ /**
447
+ * Envelope-shape guard (M2). The list envelopes (`current`/`date`/`search`) are
448
+ * `{count:number, results:array, …}`. `fetchJson` casts `as T` without checking,
449
+ * so a 200 body of the wrong shape would map to garbage. Throw a `schema_drift`
450
+ * ToolErrorCarrier (NOT a plain Error — `toToolError` has no branch for a bare
451
+ * Error, so it would degrade to `unknown`). The two `*_filings_updated_at` stamps
452
+ * are OPTIONAL (S4: a zero-result day omits them) and are NOT asserted here.
453
+ */
454
+ export function assertInspectionEnvelope(
455
+ json: unknown,
456
+ endpoint: string,
457
+ ): asserts json is InspectionEnvelope {
458
+ const j = json as { count?: unknown; results?: unknown } | null;
459
+ if (
460
+ json === null ||
461
+ typeof json !== "object" ||
462
+ typeof j?.count !== "number" ||
463
+ !Array.isArray(j?.results)
464
+ ) {
465
+ throw new ToolErrorCarrier({
466
+ kind: "schema_drift",
467
+ message: `Federal Register public-inspection envelope drift at ${endpoint}: expected {count:number, results:array}.`,
468
+ retryable: false,
469
+ upstreamEndpoint: endpoint,
470
+ });
471
+ }
472
+ }
473
+
474
+ function mapInspectionRow(r: RawInspectionRow) {
475
+ const typeVerbatim = str(r.type);
476
+ return {
477
+ documentNumber: str(r.document_number),
478
+ type: typeVerbatim,
479
+ typeCode: TYPE_MAP[r.type ?? ""] ?? "UNKNOWN",
480
+ title: str(r.title),
481
+ filedAt: str(r.filed_at),
482
+ publicationDate: str(r.publication_date),
483
+ // null-never-0; same-day survives as 0; negative anomaly verbatim.
484
+ leadDays: computeLeadDays(r.filed_at, r.publication_date),
485
+ filingType: str(r.filing_type),
486
+ isSpecialFiling: r.filing_type === "special",
487
+ // agencies[] surfaced UNFLATTENED — a doc can name multiple agencies.
488
+ agencies: (Array.isArray(r.agencies) ? r.agencies : []).map((a) => ({
489
+ rawName: str(a.raw_name),
490
+ name: str(a.name),
491
+ id: num(a.id),
492
+ slug: str(a.slug),
493
+ url: str(a.url),
494
+ jsonUrl: str(a.json_url),
495
+ parentId: num(a.parent_id),
496
+ })),
497
+ htmlUrl: str(r.html_url),
498
+ pdfUrl: str(r.pdf_url),
499
+ rawTextUrl: str(r.raw_text_url),
500
+ docketNumbers: (Array.isArray(r.docket_numbers) ? r.docket_numbers : [])
501
+ .map((s) => str(s))
502
+ .filter((s): s is string => s !== null),
503
+ numPages: num(r.num_pages),
504
+ subjects: [r.subject_1, r.subject_2, r.subject_3]
505
+ .map((s) => str(s))
506
+ .filter((s): s is string => s !== null),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * `fed_register_public_inspection` — the pre-publication leading indicator.
512
+ * mode {current, date, search}; fetch-once + client-side window; every response
513
+ * carries the pre-publication caveat + the leadDays/special-vs-regular notes.
514
+ */
515
+ export async function publicInspection(input: FedRegPublicInspectionInput) {
516
+ const mode: FedRegPublicInspectionMode = input.mode ?? "current";
517
+ const limit = input.limit ?? 20;
518
+ const offset = input.offset ?? 0;
519
+
520
+ // `term` is only meaningful in search mode — never silently full-text a
521
+ // non-search mode.
522
+ if (input.term && mode !== "search") {
523
+ throw new ToolErrorCarrier({
524
+ kind: "invalid_input",
525
+ message: `\`term\` is only valid in mode='search' (got mode='${mode}').`,
526
+ retryable: false,
527
+ upstreamEndpoint: "federal-register:public-inspection",
528
+ });
529
+ }
530
+
531
+ // ── mode=date validation: regex + real-calendar round-trip (S1) + plausibility
532
+ // bound (S5), ALL pre-fetch. An off-calendar available_on (2026-02-30)
533
+ // returns HTTP 500 that fetchWithRetry would mis-taxonomize as a retryable
534
+ // outage — so a bad date MUST be rejected with `invalid_input` and ZERO fetch.
535
+ let asOfDate: string | null = null;
536
+ if (mode === "date") {
537
+ const date = input.date;
538
+ if (!date || !PI_DATE_RE.test(date)) {
539
+ throw new ToolErrorCarrier({
540
+ kind: "invalid_input",
541
+ message: `mode='date' requires a \`date\` matching YYYY-MM-DD (got ${JSON.stringify(date)}).`,
542
+ retryable: false,
543
+ upstreamEndpoint: "federal-register:public-inspection:date",
544
+ });
545
+ }
546
+ const parts = date.split("-");
547
+ const y = Number(parts[0]);
548
+ const m = Number(parts[1]);
549
+ const d = Number(parts[2]);
550
+ const dt = new Date(Date.UTC(y, m - 1, d));
551
+ if (
552
+ dt.getUTCFullYear() !== y ||
553
+ dt.getUTCMonth() !== m - 1 ||
554
+ dt.getUTCDate() !== d
555
+ ) {
556
+ throw new ToolErrorCarrier({
557
+ kind: "invalid_input",
558
+ message: `date '${date}' is not a real calendar date (off-calendar available_on returns HTTP 500 upstream — rejected before any fetch).`,
559
+ retryable: false,
560
+ upstreamEndpoint: "federal-register:public-inspection:date",
561
+ });
562
+ }
563
+ const currentYear = new Date().getUTCFullYear();
564
+ if (y < 1994 || y > currentYear + 1) {
565
+ throw new ToolErrorCarrier({
566
+ kind: "invalid_input",
567
+ message: `date year ${y} is outside the plausible window [1994, ${currentYear + 1}].`,
568
+ retryable: false,
569
+ upstreamEndpoint: "federal-register:public-inspection:date",
570
+ });
571
+ }
572
+ asOfDate = date;
573
+ }
574
+
575
+ const url = buildPublicInspectionUrl(mode, {
576
+ date: asOfDate ?? undefined,
577
+ term: input.term,
578
+ });
579
+ const endpoint = `federal-register:public-inspection:${mode}`;
580
+
581
+ // M2: a 200 body that is NOT valid JSON → fetchJson's `.json()` throws a
582
+ // SyntaxError → reclassify as `schema_drift` THROW (never a fake empty). A
583
+ // ToolErrorCarrier from fetchWithRetry (503/404/429) is rethrown UNCHANGED.
584
+ let json: unknown;
585
+ try {
586
+ json = await fetchJson<unknown>(url);
587
+ } catch (e) {
588
+ if (e instanceof SyntaxError) {
589
+ throw new ToolErrorCarrier({
590
+ kind: "schema_drift",
591
+ message: `Federal Register public-inspection returned a 200 non-JSON body at ${endpoint}.`,
592
+ retryable: false,
593
+ upstreamEndpoint: endpoint,
594
+ });
595
+ }
596
+ throw e;
597
+ }
598
+ assertInspectionEnvelope(json, endpoint);
599
+ const env = json;
600
+
601
+ const rawRows = env.results;
602
+ const servedTotal = env.count;
603
+ // S4: both freshness stamps are OPTIONAL — null-safe (absent → null).
604
+ const specialFilingsUpdatedAt = str(env.special_filings_updated_at);
605
+ const regularFilingsUpdatedAt = str(env.regular_filings_updated_at);
606
+
607
+ // Client-side filters (uniform across all modes — dodges the API quirks where
608
+ // available_on ignores type/per_page and the date-in-path trap).
609
+ let rows = rawRows.map(mapInspectionRow);
610
+ if (input.type) rows = rows.filter((r) => r.typeCode === input.type);
611
+ if (input.agency)
612
+ rows = rows.filter((r) => r.agencies.some((a) => a.slug === input.agency));
613
+ if (input.specialOnly) rows = rows.filter((r) => r.isSpecialFiling === true);
614
+ const filteredTotal = rows.length;
615
+ const hasClientFilter = Boolean(
616
+ input.type || input.agency || input.specialOnly,
617
+ );
618
+
619
+ // Overflow (S2): search corpus > per_page (server paginated; we fetched one
620
+ // page). Live-UNREACHABLE at the ~75-doc corpus, defensive-only.
621
+ const overflow = mode === "search" && servedTotal > rawRows.length;
622
+
623
+ let totalAvailable: number;
624
+ let totalIsLowerBound = false;
625
+ if (overflow && hasClientFilter) {
626
+ // A client filter over ONLY the fetched first page → the exact server total
627
+ // (all types) would MISREPRESENT the filtered set. Report the filtered
628
+ // first-page count as a documented LOWER BOUND.
629
+ totalAvailable = filteredTotal;
630
+ totalIsLowerBound = true;
631
+ } else if (overflow) {
632
+ // No client filter → the server's exact total is honest (returned rows are
633
+ // the lower bound, disclosed via truncated + note).
634
+ totalAvailable = servedTotal;
635
+ } else {
636
+ // The exact count of the client-filtered set (never the page length).
637
+ totalAvailable = filteredTotal;
638
+ }
639
+
640
+ const pageRows = rows.slice(offset, offset + limit);
641
+ const returned = pageRows.length;
642
+ const hasMore = offset + returned < totalAvailable;
643
+ const nextOffset = hasMore ? offset + returned : null;
644
+
645
+ const notes: string[] = [
646
+ PRE_PUBLICATION_CAVEAT,
647
+ LEADDAYS_METHOD_NOTE,
648
+ SPECIAL_REGULAR_NOTE,
649
+ ];
650
+ notes.push(
651
+ `Served ${servedTotal} document(s); after client filters (type/agency/specialOnly) → ${totalAvailable}${totalIsLowerBound ? "+ (lower bound)" : ""} pageable. The full ${mode} set was fetched in ONE request; limit/offset is a client-side window over the fetched rows.`,
652
+ );
653
+ notes.push(
654
+ "Filters type/agency/specialOnly are applied CLIENT-SIDE over the fetched set; date (→ conditions[available_on]) and term (→ conditions[term]) are server-side query params.",
655
+ );
656
+ if (pageRows.some((r) => typeof r.leadDays === "number" && r.leadDays < 0)) {
657
+ notes.push(
658
+ "At least one row has a NEGATIVE leadDays (publication_date precedes the filing date) — a source data anomaly, surfaced verbatim (not clamped/nulled).",
659
+ );
660
+ }
661
+ if (filteredTotal === 0) {
662
+ notes.push(
663
+ `No documents on public inspection for mode='${mode}'${asOfDate ? ` (available_on=${asOfDate})` : ""}${hasClientFilter ? " matching the requested filters" : ""}. This is an honest empty result, not an error.`,
664
+ );
665
+ }
666
+ if (overflow) {
667
+ notes.push(
668
+ hasClientFilter
669
+ ? `Server matched ${servedTotal} before client filters but only the first ${rawRows.length} rows were retrieved — totalAvailable is a LOWER BOUND on the client-filtered set. Narrow with a more specific term/type.`
670
+ : `Only the first ${rawRows.length} of ${servedTotal} documents were retrieved (server pagination) — totalAvailable is the exact server total; returned rows are a lower bound. Narrow with a more specific term/type.`,
671
+ );
672
+ }
673
+
674
+ const filtersApplied: string[] = ["mode"];
675
+ if (asOfDate) filtersApplied.push("date");
676
+ if (input.term) filtersApplied.push("term");
677
+ if (input.type) filtersApplied.push("type");
678
+ if (input.agency) filtersApplied.push("agency");
679
+ if (input.specialOnly) filtersApplied.push("specialOnly");
680
+
681
+ const data = {
682
+ mode,
683
+ asOfDate,
684
+ specialFilingsUpdatedAt,
685
+ regularFilingsUpdatedAt,
686
+ servedTotal,
687
+ totalAvailable,
688
+ returned,
689
+ documents: pageRows,
690
+ };
691
+
692
+ return withMeta(data, {
693
+ source:
694
+ "www.federalregister.gov/api/v1/public-inspection-documents (keyless)",
695
+ keylessMode: true,
696
+ returned,
697
+ totalAvailable,
698
+ ...(totalIsLowerBound ? { totalIsLowerBound: true } : {}),
699
+ ...(overflow ? { truncated: true } : {}),
700
+ filtersApplied,
701
+ filtersDropped: [],
702
+ fieldsUnavailable: [],
703
+ pagination: { offset, limit, hasMore, nextOffset },
704
+ notes,
705
+ });
706
+ }