@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
@@ -0,0 +1,296 @@
1
+ /**
2
+ * datagov-catalog.ts — data.gov v4 Catalog API (`api.gsa.gov/technology/datagov/v4`)
3
+ * federal DATASET DISCOVERY (ADR-0046, resilience-initiative Phase 3).
4
+ *
5
+ * WHAT IT ADDS: data.gov RETIRED its CKAN `package_search` endpoint in 2025; the
6
+ * v4 Catalog API is its replacement. This restores federal open-dataset DISCOVERY
7
+ * across all publishing agencies (hundreds of thousands of datasets) as a NEW
8
+ * keyless-first source. It is a SEPARATE host from datagov.ts's api.data.gov trio
9
+ * (Regulations.gov / Congress.gov), but it shares the IDENTICAL api.data.gov key
10
+ * (`api.gsa.gov` accepts the same DATA_GOV_API_KEY / DEMO_KEY via `X-Api-Key`), so
11
+ * it REUSES the audited `datagovKey.ts` key seam verbatim (keyHeader / keyModeLabel
12
+ * / pushKeyNote) — keylessMode:false (genuinely keyed, mirroring the regulations trio).
13
+ *
14
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
15
+ * (redirect:"error", the X-Api-Key header) / `driftError` / `str` (coerce.ts,
16
+ * null-never-empty-string) / `withMeta`·`buildMeta` / `ResponseMeta.nextCursor`,
17
+ * and MIRRORS datagov.ts's `searchDockets` schema_drift catch-ladder verbatim.
18
+ *
19
+ * ★ SSRF: the host is a compile-time literal (`DATAGOV_CATALOG_HOST`); every filter
20
+ * (`_q`/`organization`/`_size`/`_format`) rides in a MODULE-BUILT `URLSearchParams`
21
+ * assembled key-by-key from validated typed args — NO raw-query passthrough. The
22
+ * opaque `cursor` is charclass-validated (`^[A-Za-z0-9+/=_-]{1,4096}$`, rejecting
23
+ * `../` / spaces / `%`) BEFORE it rides the `after=` query param. A post-construction
24
+ * hostname/protocol assertion + `redirect:"error"` lock it (fail closed on any
25
+ * off-host 3xx — a 3xx off api.gsa.gov could carry the X-Api-Key header away).
26
+ *
27
+ * ★ THE HONESTY PILLARS (P1-P4, captured live 2026-07-14 — the v4 facts):
28
+ * P1 (NO total): the v4 search response is `{ after, results, sort }` — it reports
29
+ * NO match count. `totalAvailable = null` (NEVER results.length, NEVER a fabricated
30
+ * total). Pagination is an OPAQUE `after` cursor: `hasMore = after is a non-empty
31
+ * string`; `nextCursor = hasMore ? after : null` (passed back VERBATIM as the next
32
+ * `cursor` argument). offset/nextOffset are null (a numeric offset is meaningless).
33
+ * P2: getJson→fetchWithRetry THROWS on 429 (rate_limited — very likely at DEMO_KEY's
34
+ * ~10/hr), 5xx (upstream_unavailable), timeout — NEVER a fake empty. A genuine
35
+ * no-match (results:[], no after) ⇒ honest empty (datasets:[], returned:0,
36
+ * nextCursor:null, complete:true).
37
+ * P3: every scalar via `str` (null-never-empty-string — a missing accessLevel /
38
+ * license / landingPage is null, NEVER "").
39
+ * P4: `body.results` absent/non-array ⇒ driftError; a 200 non-JSON body ⇒ schema_drift
40
+ * via the catch-ladder (ToolErrorCarrier rethrow FIRST so a 429/5xx keeps its
41
+ * taxonomy → SyntaxError→driftError → bare rethrow).
42
+ * accessLevel is surfaced VERBATIM (public / restricted public / non-public), null
43
+ * when absent — the consumer judges the dataset's openness (this tool only
44
+ * DISCOVERS datasets; it does not ingest distributions).
45
+ */
46
+
47
+ import { ToolErrorCarrier } from "./errors.js";
48
+ import { getJson, driftError } from "./datasource.js";
49
+ import { str } from "./coerce.js";
50
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
51
+ // The SHARED api.data.gov key seam (ADR-0010 §2). api.gsa.gov accepts the SAME
52
+ // DATA_GOV_API_KEY / DEMO_KEY via the X-Api-Key header, so this is a THIRD consumer
53
+ // of the audited key discipline — a key-leak regression now fails this suite too.
54
+ import { keyHeader, keyModeLabel, pushKeyNote } from "./datagovKey.js";
55
+
56
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
57
+ export const DATAGOV_CATALOG_HOST = "api.gsa.gov";
58
+ const DATAGOV_CATALOG_BASE = "/technology/datagov/v4";
59
+ const DATAGOV_CATALOG_SEARCH_PATH = `${DATAGOV_CATALOG_BASE}/search`;
60
+ // HOST+path label — surfaces in ToolError.upstreamEndpoint; the key rides ONLY in
61
+ // the X-Api-Key header, so no token can ever appear here.
62
+ const DATAGOV_CATALOG_LABEL = "datagov-catalog:/technology/datagov/v4/search";
63
+
64
+ const DATAGOV_CATALOG_SOURCE = (mode: string) =>
65
+ `${DATAGOV_CATALOG_HOST} via data.gov v4 Catalog API (${mode})`;
66
+
67
+ // The opaque `after` cursor grammar (SSRF + injection guard). data.gov's cursor is
68
+ // a base64/URL-safe token — `+/=_-`, no `%` (URLSearchParams would double-encode a
69
+ // literal `%` ('%2e'→'%252e') and corrupt the cursor; the real tokens carry none).
70
+ // Validated BEFORE the value rides `after=` — a `../` / space / `%` ⇒ invalid_input,
71
+ // 0 fetch (mirrors clinicaltrials CT_TOKEN_RE).
72
+ export const DATAGOV_CURSOR_RE = new RegExp("^[A-Za-z0-9+/=_-]{1,4096}$");
73
+
74
+ // The P1 no-total disclosure — the load-bearing honesty caveat carried EVERY response.
75
+ const DATAGOV_CATALOG_NO_TOTAL_NOTE =
76
+ "data.gov catalog does not report a total match count — use nextCursor to page; totalAvailable is unknown (null). Pagination is an OPAQUE cursor (offset/nextOffset are meaningless/null); pass _meta.nextCursor back as the `cursor` argument. nextCursor:null / hasMore:false means this is the last page.";
77
+
78
+ // ─── The curated dataset shape ────────────────────────────────────
79
+ export type CatalogDistribution = {
80
+ title: string | null;
81
+ format: string | null;
82
+ };
83
+
84
+ export type CatalogDataset = {
85
+ id: string | null;
86
+ title: string | null;
87
+ organization: string | null;
88
+ description: string | null;
89
+ accessLevel: string | null;
90
+ license: string | null;
91
+ landingPage: string | null;
92
+ modified: string | null;
93
+ lastHarvested: string | null;
94
+ keywords: string[];
95
+ themes: string[];
96
+ distributions: CatalogDistribution[];
97
+ identifier: string | null;
98
+ };
99
+
100
+ /** A string[] from a mixed value (drops null/empty via str), else [] when absent/non-array. */
101
+ function strArray(x: unknown): string[] {
102
+ if (!Array.isArray(x)) return [];
103
+ return x.map((v) => str(v)).filter((v): v is string => v !== null);
104
+ }
105
+
106
+ /**
107
+ * Map ONE `results[]` row → the curated dataset shape. Every scalar via `str`
108
+ * (null-never-empty-string — a missing accessLevel/license/landingPage is null,
109
+ * NEVER ""). description falls back from the row-level to the DCAT-US field.
110
+ * keywords/themes/distributions default to [] (an honest "none listed"); a
111
+ * distribution is kept only when it carries a title OR a format.
112
+ */
113
+ function mapDataset(row: unknown): CatalogDataset {
114
+ const r = (row ?? {}) as Record<string, unknown>;
115
+ const dcat = (r.dcat ?? {}) as Record<string, unknown>;
116
+ const distributions: CatalogDistribution[] = Array.isArray(dcat.distribution)
117
+ ? (dcat.distribution as unknown[])
118
+ .map((d) => {
119
+ const it = (d ?? {}) as Record<string, unknown>;
120
+ return { title: str(it.title), format: str(it.format ?? it.mediaType) };
121
+ })
122
+ .filter((x) => x.title !== null || x.format !== null)
123
+ : [];
124
+ return {
125
+ id: str(r.slug),
126
+ title: str(r.title),
127
+ organization: str(r.organization),
128
+ description: str(r.description ?? dcat.description),
129
+ accessLevel: str(dcat.accessLevel),
130
+ license: str(dcat.license),
131
+ landingPage: str(dcat.landingPage),
132
+ modified: str(dcat.modified),
133
+ lastHarvested: str(r.last_harvested_date),
134
+ keywords: strArray(r.keyword),
135
+ themes: strArray(r.theme),
136
+ distributions,
137
+ identifier: str(dcat.identifier),
138
+ };
139
+ }
140
+
141
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
142
+ /**
143
+ * GET one data.gov v4 Catalog JSON resource. `path` is a fixed base path; all
144
+ * caller params ride in `params` (URLSearchParams, encoded). Builds
145
+ * `https://${DATAGOV_CATALOG_HOST}${path}?${params}` on the FIXED host, asserts the
146
+ * CONSTRUCTED URL's hostname === the host over https (belt-and-suspenders), sets
147
+ * `redirect:"error"` (an off-host 3xx must NOT be followed — it could carry the
148
+ * X-Api-Key header to a foreign host), and attaches the key ONLY in the X-Api-Key
149
+ * header. `label` is host+path only (→ ToolError.upstreamEndpoint).
150
+ */
151
+ async function getDatagovCatalog(
152
+ path: string,
153
+ label: string,
154
+ params: URLSearchParams,
155
+ ): Promise<unknown> {
156
+ const qs = params.toString();
157
+ const url = `https://${DATAGOV_CATALOG_HOST}${path}${qs ? `?${qs}` : ""}`;
158
+ const built = new URL(url);
159
+ if (built.hostname !== DATAGOV_CATALOG_HOST || built.protocol !== "https:") {
160
+ throw new ToolErrorCarrier({
161
+ kind: "invalid_input",
162
+ message: `Constructed data.gov catalog URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(DATAGOV_CATALOG_HOST)} over https — refusing to fetch (SSRF safety).`,
163
+ retryable: false,
164
+ upstreamEndpoint: label,
165
+ });
166
+ }
167
+ // The key rides in the X-Api-Key header ONLY (never the URL/label/_meta);
168
+ // redirect:"error" (fail closed on any off-host 3xx).
169
+ return getJson(url, { label, headers: keyHeader(), redirect: "error" });
170
+ }
171
+
172
+ // ─── Tool: datagov_search_datasets ────────────────────────────────
173
+ export type DatagovSearchDatasetsArgs = {
174
+ query?: string;
175
+ organization?: string;
176
+ limit?: number;
177
+ cursor?: string;
178
+ };
179
+
180
+ /**
181
+ * Search the data.gov v4 dataset catalog (the CKAN-retirement replacement).
182
+ * Filters: `query` (→_q), `organization` (publisher slug), `limit` (→_size),
183
+ * `cursor` (→after, the opaque continuation). The query is MODULE-BUILT from
184
+ * validated typed args through URLSearchParams (NO raw passthrough); `_format=json`
185
+ * is ALWAYS appended. Returns curated dataset rows + honest `_meta`: totalAvailable
186
+ * is NULL (the v4 API reports no count — P1), the opaque-cursor continuation, the
187
+ * accessLevel openness field surfaced verbatim, and the DEMO_KEY rate disclosure.
188
+ */
189
+ export async function searchDatasets(
190
+ args: DatagovSearchDatasetsArgs,
191
+ ): Promise<MetaBundle> {
192
+ const label = DATAGOV_CATALOG_LABEL;
193
+ const limit = args.limit ?? 20;
194
+
195
+ // ── Belt-and-suspenders cursor grammar (behind the server's Zod). A bad cursor
196
+ // would ride `after=` and either 400 or silently mis-page; reject it pre-fetch
197
+ // (0 network call) so a `../` / space / `%` can never reach the query. ──
198
+ if (args.cursor !== undefined && !DATAGOV_CURSOR_RE.test(args.cursor)) {
199
+ throw new ToolErrorCarrier({
200
+ kind: "invalid_input",
201
+ message: `Invalid cursor (opaque continuation token) — must be a ≤4096-char base64/URL-safe token (no spaces, '../', or '%'). Pass back the _meta.nextCursor from the previous page.`,
202
+ retryable: false,
203
+ upstreamEndpoint: label,
204
+ });
205
+ }
206
+
207
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
208
+ // passthrough). _format=json is ALWAYS appended. ──
209
+ const params = new URLSearchParams();
210
+ const filtersApplied: string[] = [];
211
+ if (args.query !== undefined) {
212
+ params.set("_q", args.query);
213
+ filtersApplied.push("query");
214
+ }
215
+ if (args.organization !== undefined) {
216
+ params.set("organization", args.organization);
217
+ filtersApplied.push("organization");
218
+ }
219
+ params.set("_size", String(limit));
220
+ if (args.cursor !== undefined) {
221
+ params.set("after", args.cursor);
222
+ filtersApplied.push("cursor");
223
+ }
224
+ params.set("_format", "json");
225
+
226
+ // ── The typed catch-ladder (datagov.ts searchDockets shape, VERBATIM). Preserve
227
+ // the 429/404/5xx/400/timeout ToolErrorCarrier taxonomy FIRST (LOAD-BEARING:
228
+ // the DEMO_KEY-~10/hr 429→rate_limited frontier would regress to schema_drift
229
+ // under a broader catch); reclassify a 200 non-JSON `.json()` SyntaxError to
230
+ // schema_drift SECOND; bare-rethrow LAST. The host-assert ToolErrorCarrier is
231
+ // also rethrown first. ──
232
+ let body: unknown;
233
+ try {
234
+ body = await getDatagovCatalog(DATAGOV_CATALOG_SEARCH_PATH, label, params);
235
+ } catch (e) {
236
+ if (e instanceof ToolErrorCarrier) throw e;
237
+ if (e instanceof SyntaxError)
238
+ throw driftError(
239
+ label,
240
+ "data.gov catalog returned a non-JSON body at HTTP 200 — schema drift.",
241
+ );
242
+ throw e;
243
+ }
244
+
245
+ const b = (body ?? {}) as { results?: unknown; after?: unknown };
246
+
247
+ // ── P4: `results` MUST be an array (a missing/string/null results is drift,
248
+ // never a fabricated empty — a TypeError must never mask drift as
249
+ // upstream_unavailable). ──
250
+ if (!Array.isArray(b.results)) {
251
+ throw driftError(
252
+ label,
253
+ "data.gov catalog shape drift — /search response.results must be an array.",
254
+ );
255
+ }
256
+
257
+ const datasets = (b.results as unknown[]).map(mapDataset);
258
+ const returned = datasets.length;
259
+
260
+ // ── P1: NO total is reported ⇒ totalAvailable = null (NEVER results.length,
261
+ // NEVER a fabricated total). Pagination is the opaque `after` cursor: hasMore
262
+ // from cursor-presence; nextCursor passed back VERBATIM (never derived). ──
263
+ const rawAfter = b.after;
264
+ const hasMore = typeof rawAfter === "string" && rawAfter.length > 0;
265
+ const nextCursor = hasMore ? (rawAfter as string) : null;
266
+
267
+ const notes: string[] = [DATAGOV_CATALOG_NO_TOTAL_NOTE];
268
+ pushKeyNote(notes);
269
+ if (filtersApplied.length === 0) {
270
+ notes.push(
271
+ "No filters were applied — this is an unscoped scan of the WHOLE data.gov catalog. Add `query` and/or `organization` for a meaningful scoped result set.",
272
+ );
273
+ }
274
+
275
+ return withMeta(
276
+ { datasets },
277
+ {
278
+ source: DATAGOV_CATALOG_SOURCE(keyModeLabel()),
279
+ keylessMode: false, // genuinely keyed (api.data.gov X-Api-Key)
280
+ returned,
281
+ // P1 — the v4 API reports NO match count. NULL, never results.length.
282
+ totalAvailable: null,
283
+ // complete is DERIVED by buildMeta: a page with no `after` (hasMore:false) and
284
+ // no dropped filters ⇒ complete:true (an honest exact empty on a no-match); a
285
+ // page WITH an `after` cursor ⇒ hasMore:true ⇒ complete:false.
286
+ filtersApplied,
287
+ filtersDropped: [],
288
+ fieldsUnavailable: [],
289
+ // Cursor page: offset/nextOffset null (no numeric offset); continuation is
290
+ // nextCursor (the opaque `after` token, passed back verbatim as `cursor`).
291
+ pagination: { offset: null, limit, hasMore, nextOffset: null },
292
+ nextCursor,
293
+ notes,
294
+ } satisfies Partial<ResponseMeta>,
295
+ );
296
+ }