@cliwant/mcp-sam-gov 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
package/src/ckan.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * CKAN `datastore_search` — keyless open data for state / local (SLED) portals.
3
+ *
4
+ * Second SLED family after Socrata (ADR-0006) and the FIRST source built on the
5
+ * R2 `DataSource` port (ADR-0005): CKAN writes ZERO fetch/coercion/error code —
6
+ * it reuses `getJson` / `driftError` / `num`·`str` / `withMeta` and COPIES (does
7
+ * NOT import) Socrata's SSRF + honesty PATTERN. Fully PUBLIC, KEYLESS — the CKAN
8
+ * datastore is anonymous-readable, so there is NO token anywhere in scope (no
9
+ * `headers`, byte-clean init).
10
+ *
11
+ * CKAN is Socrata's sibling — another multi-portal keyless open-data platform
12
+ * reached by swapping a hostname + a dataset id on one identical JSON Action API.
13
+ * We keep the guard per-source (host lists, id grammar, and the response envelope
14
+ * all differ — ADR-0006 §7) and do NOT unify with `socrata.ts`.
15
+ * Row query: https://{host}/api/3/action/datastore_search?resource_id={uuid}&…
16
+ * Discovery: https://{host}/api/3/action/package_search?q=…&rows=N
17
+ *
18
+ * Unlike SODA (a bare array, NO total), CKAN WRAPS records in an envelope that
19
+ * carries `result.total` and `result.total_was_estimated` DIRECTLY — so no
20
+ * count(*) companion is needed; the total is read from the body.
21
+ *
22
+ * ★ SSRF GUARD (policy① — the central design risk). COPY Socrata's 4-step order:
23
+ * (1) `host` ∈ CKAN_HOSTS — a Zod enum at the server boundary (single source of
24
+ * truth) AND a belt-and-suspenders `Set.has` check in the fetch fn → else
25
+ * invalid_input BEFORE any fetch;
26
+ * (2) `resourceId` matches the CKAN UUID grammar — the server's Zod
27
+ * `.length(36).regex(UUID_RE)` (NO `.trim()`, NO `i` flag) PLUS a runtime
28
+ * recheck in the fetch fn (M1 — parallel to the host `Set.has` recheck;
29
+ * Socrata re-checks datasetId too). m1: JS `$` does NOT match before a
30
+ * trailing `\n` (unlike Python), so the regex ALONE rejects a newline;
31
+ * `.length(36)` is belt-and-suspenders, not the primary newline guard.
32
+ * m2: the grammar is LOWERCASE hex only — every live id on every host is a
33
+ * 36-char lowercase UUID (confirmed) — so we keep it strict (no `i` flag);
34
+ * (3) FIXED path `https://${host}/api/3/action/datastore_search` (only `host`
35
+ * interpolates into the authority; `resourceId` goes through URLSearchParams,
36
+ * NEVER the path). Then ASSERT `new URL(built).hostname === host` and
37
+ * `protocol === "https:"` → invalid_input on mismatch;
38
+ * (4) all params (`resource_id`, `q`, `filters`, `sort`, `limit`, `offset`) via
39
+ * URLSearchParams — encoded values, no host-alteration surface. Bad
40
+ * `filters`/`sort` are upstream-validated (409 → invalid_input, surfaced).
41
+ *
42
+ * B1 (SSRF, redirect) — `datastore_search`/`package_search` are direct-JSON
43
+ * endpoints, so a 3xx off an allowlisted host is anomalous (migration / DNS
44
+ * hijack / reused domain) and would otherwise be followed off-allowlist past the
45
+ * pre-check. We set `redirect:"error"` in EVERY getJson call (getJson forwards
46
+ * `init.redirect` to fetch verbatim — a redirect throws, its body is never read).
47
+ *
48
+ * Security non-goal (explicit): we expose ONLY `datastore_search` (parameterized)
49
+ * and `package_search`. We do NOT expose `datastore_search_sql` — the raw-SQL
50
+ * CKAN endpoint IS an injection surface and is out of scope. `filters` is a
51
+ * CONSTRAINED object we `JSON.stringify` (Q3), never caller-supplied raw SQL/JSON.
52
+ * We NEVER pass `total_estimation_threshold` or `include_total:false` (Q4) — we
53
+ * always request an EXACT total; the estimate path fires only if a host is
54
+ * server-side reconfigured, hence defensive.
55
+ *
56
+ * HONESTY (`_meta`) — the load-bearing B1 fix (ADR-0006 v2):
57
+ * - EXACT total (the default — `total_estimation_threshold` is null on all
58
+ * hosts → `total_was_estimated:false`): `totalAvailable = num(result.total)`
59
+ * (exact), `hasMore = offset + returned < total` (or the B2 hedge
60
+ * `returned >= limit` if `total` is absent — a host running include_total:
61
+ * false). Genuine-empty (records:[], total:0) → complete:true / total:0.
62
+ * - ESTIMATED total (`total_was_estimated:true` — rare/defensive): the estimate
63
+ * is a Postgres `reltuples` approximation that can land ABOVE OR BELOW the
64
+ * truth (live-verified 345285 vs 344504 — an OVERSHOOT), so it is NOT a lower
65
+ * bound and MUST NOT drive pagination. Pass `totalAvailable:null` (so
66
+ * buildMeta's `totalProvesTruncation` does not fire on the estimate),
67
+ * `hasMore = returned >= limit` (paginate by page-fullness — the anti-livelock
68
+ * guard), `totalIsEstimated:true`, and a `_meta.notes` disclosure carrying the
69
+ * estimate value.
70
+ * - `success:false` (even on HTTP 200) or `result`/`records` missing/non-array
71
+ * → THROW (never a fake empty). Modern hosts surface 404/409 (→ not_found /
72
+ * invalid_input via errorFromResponse); this guard covers a 200+success:false
73
+ * host/proxy. outage/5xx/timeout → getJson throws, never `[]`.
74
+ * - `num`/`str` are null-never-0 (a missing value is honest "unknown", never 0).
75
+ *
76
+ * ALLOWLIST — LIVE-VERIFIED 2026-07-12 (each `.gov`, government-controlled, a live
77
+ * CKAN datastore; each carries a real datastore-active sample resource_id that
78
+ * returned HTTP 200 + the envelope above). NO commercial-vendor domains (the
79
+ * Socrata Tyler-Technologies M1 mistake is avoided). `data.virginia.gov` is the
80
+ * same VA that CHURNED OFF Socrata (excluded from the SODA allowlist) — it belongs
81
+ * HERE on CKAN. `data.ok.gov` is DEFERRED to SOURCE_BACKLOG (only a 9-row thin
82
+ * datastore verified — require a 2nd procurement-relevant >1k-row resource before
83
+ * adding). `catalog.data.gov` (federal harvester, no active datastore) is dropped.
84
+ * Adding a host later = a CKAN_HOSTS SOURCE edit + a live `datastore_search?
85
+ * limit=1` verification + an ownership note + a test-fixture note — NEVER a free
86
+ * runtime param.
87
+ */
88
+
89
+ import { ToolErrorCarrier } from "./errors.js";
90
+ import { getJson, driftError } from "./datasource.js";
91
+ import { num, str } from "./coerce.js";
92
+ import { memoize } from "./cache.js";
93
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
94
+
95
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
96
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
97
+ // `num` from this module (ckan.num === socrata.num === treasury.num === coerce.num).
98
+ export { num };
99
+
100
+ // ─── Curated allowlist (SSRF core) ────────────────────────────────
101
+ // A frozen list of live-verified (2026-07-12) CKAN datastore hosts. The Zod
102
+ // `host` enum in server.ts is built FROM this array (single source of truth), and
103
+ // the fetch fns re-check membership (belt-and-suspenders). Each entry carries a
104
+ // real sample resource_id (datastore_active) confirmed to return HTTP 200 + the
105
+ // {success, result:{records, total, total_was_estimated, fields}} envelope.
106
+ // Re-verify each with `GET https://{host}/api/3/action/datastore_search?
107
+ // resource_id={uuid}&limit=1` when touching this list.
108
+ export const CKAN_HOSTS = [
109
+ // State of California (CDT/GovOps) — Statewide Purchase Order Data 2012–2015,
110
+ // resource_id bb82edc5-9c78-44e2-8947-68ece26197c5 (~344,504 rows).
111
+ "data.ca.gov",
112
+ // Commonwealth of Virginia (VITA) — the VA that churned off Socrata. Norfolk
113
+ // SWaM Certified Businesses, resource_id f6804560-bf9e-44a4-92bf-bf3dd7d1fd60.
114
+ "data.virginia.gov",
115
+ // City of Boston (DoIT) — Checkbook Explorer (city spend), resource_id
116
+ // d22fdd5c-7e4c-41b7-a3eb-dfc57a87b245 (~101,465 rows).
117
+ "data.boston.gov",
118
+ ] as const;
119
+
120
+ export type CkanHost = (typeof CKAN_HOSTS)[number];
121
+
122
+ const CKAN_HOST_SET: ReadonlySet<string> = new Set(CKAN_HOSTS);
123
+
124
+ // A CKAN datastore resource_id is EXACTLY a 36-char LOWERCASE hex UUID. m1: JS
125
+ // `$` does NOT admit a trailing "\n" (unlike Python), so this regex alone rejects
126
+ // a newline; `.length(36)` (server + the runtime recheck below) is belt-and-
127
+ // suspenders. m2: lowercase-only — no `i` flag (all live ids are lowercase).
128
+ const UUID_RE =
129
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
130
+
131
+ // ─── HONESTY-CRITICAL coercions (null, never 0, for absent) ───────
132
+ // `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
133
+ // (imported above): null/undefined, the literal "null", ""/whitespace
134
+ // (Number("") is 0!), and "(-)"/"-" all become null (never 0).
135
+
136
+ // ─── fetch layer ──────────────────────────────────────────────────
137
+ export type CkanRecord = Record<string, unknown>;
138
+
139
+ /**
140
+ * GET one CKAN `datastore_search` resource. SSRF guard (belt-and-suspenders
141
+ * behind the server's Zod enum+regex): host ∈ allowlist, resourceId is a 36-char
142
+ * lowercase UUID (M1 runtime recheck), and the CONSTRUCTED URL's hostname === host
143
+ * (https). Sets `redirect:"error"` (B1); NO headers (keyless — the datastore is
144
+ * anonymous). Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited;
145
+ * 5xx → upstream_unavailable; 404 → not_found; 409/400 → invalid_input). Returns
146
+ * the parsed JSON (unknown; the caller validates the envelope shape).
147
+ */
148
+ async function getCkanDatastore(
149
+ host: string,
150
+ resourceId: string,
151
+ params: URLSearchParams,
152
+ ): Promise<unknown> {
153
+ if (!CKAN_HOST_SET.has(host)) {
154
+ throw new ToolErrorCarrier({
155
+ kind: "invalid_input",
156
+ message: `CKAN host ${JSON.stringify(host)} is not on the curated allowlist. Allowed: ${CKAN_HOSTS.join(", ")}.`,
157
+ retryable: false,
158
+ });
159
+ }
160
+ // M1 — runtime belt-and-suspenders on resourceId (parallel to the host Set.has
161
+ // recheck; faithful copy of Socrata's re-check). No URLSearchParams-bypass
162
+ // exists, but this is defense-in-depth for the SSRF path-interpolation vector.
163
+ if (resourceId.length !== 36 || !UUID_RE.test(resourceId)) {
164
+ throw new ToolErrorCarrier({
165
+ kind: "invalid_input",
166
+ message: `Invalid CKAN resourceId ${JSON.stringify(resourceId)} — expected a 36-char lowercase UUID ([0-9a-f]{8}-{4}-{4}-{4}-{12}).`,
167
+ retryable: false,
168
+ });
169
+ }
170
+ params.set("resource_id", resourceId);
171
+ const url = `https://${host}/api/3/action/datastore_search?${params.toString()}`;
172
+ // Belt-and-suspenders: the FIXED path leaves only host to interpolate into the
173
+ // authority; assert the built URL cannot have been steered off-host.
174
+ const built = new URL(url);
175
+ if (built.hostname !== host || built.protocol !== "https:") {
176
+ throw new ToolErrorCarrier({
177
+ kind: "invalid_input",
178
+ message: `Constructed CKAN URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted host ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
179
+ retryable: false,
180
+ });
181
+ }
182
+ // Shared fetch envelope (ADR-0005): keyless ⇒ NO headers key (byte-clean init);
183
+ // B1 redirect:"error"; host-only label → ToolError.upstreamEndpoint.
184
+ return getJson(url, { label: "ckan:" + host, redirect: "error" });
185
+ }
186
+
187
+ /**
188
+ * GET a CKAN `package_search` (discovery). Same host allowlist + fixed path +
189
+ * hostname assertion + redirect:"error" policy as getCkanDatastore.
190
+ */
191
+ async function getCkanPackageSearch(
192
+ host: string,
193
+ params: URLSearchParams,
194
+ ): Promise<unknown> {
195
+ if (!CKAN_HOST_SET.has(host)) {
196
+ throw new ToolErrorCarrier({
197
+ kind: "invalid_input",
198
+ message: `CKAN host ${JSON.stringify(host)} is not on the curated allowlist. Allowed: ${CKAN_HOSTS.join(", ")}.`,
199
+ retryable: false,
200
+ });
201
+ }
202
+ const url = `https://${host}/api/3/action/package_search?${params.toString()}`;
203
+ const built = new URL(url);
204
+ if (built.hostname !== host || built.protocol !== "https:") {
205
+ throw new ToolErrorCarrier({
206
+ kind: "invalid_input",
207
+ message: `Constructed CKAN catalog URL host ${JSON.stringify(built.hostname)} does not match the allowlisted host ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
208
+ retryable: false,
209
+ });
210
+ }
211
+ return getJson(url, { label: "ckan:" + host, redirect: "error" });
212
+ }
213
+
214
+ // ─── map + meta helpers ───────────────────────────────────────────
215
+ const VALUE_TYPING_NOTE =
216
+ "Record values follow result.fields[].type (int/text/numeric/timestamp); a numeric column may arrive as a JSON number OR a string — parse client-side. A missing value is absent, never 0.";
217
+
218
+ const SOURCE_SUFFIX = "via CKAN datastore_search (keyless)";
219
+
220
+ /**
221
+ * The `body.success === false` taxonomy (m3). Parse `body.error.__type`:
222
+ * "Not Found Error" → not_found
223
+ * "Validation Error" → invalid_input
224
+ * anything else → schema_drift (NOT a blanket not_found/driftError of the
225
+ * wrong kind — an unknown __type is genuine drift)
226
+ * This is the DEFENSIVE guard for a host/proxy that returns HTTP 200 with
227
+ * success:false (modern hosts surface real 404/409, handled by errorFromResponse
228
+ * before we ever see the body).
229
+ */
230
+ function throwCkanApiError(host: string, err: unknown): never {
231
+ const type = str((err as { __type?: unknown } | null | undefined)?.__type);
232
+ const message = str((err as { message?: unknown } | null | undefined)?.message);
233
+ const label = "ckan:" + host;
234
+ if (type === "Not Found Error") {
235
+ throw new ToolErrorCarrier({
236
+ kind: "not_found",
237
+ message: `CKAN ${host} reported Not Found${message ? `: ${message}` : "."}`,
238
+ retryable: false,
239
+ upstreamEndpoint: label,
240
+ });
241
+ }
242
+ if (type === "Validation Error") {
243
+ throw new ToolErrorCarrier({
244
+ kind: "invalid_input",
245
+ message: `CKAN ${host} reported a Validation Error${message ? `: ${message}` : "."}`,
246
+ retryable: false,
247
+ upstreamEndpoint: label,
248
+ });
249
+ }
250
+ throw driftError(
251
+ label,
252
+ `CKAN ${host} returned success:false with an unrecognized error type ${JSON.stringify(type)} — treating as schema drift.`,
253
+ );
254
+ }
255
+
256
+ // ─── Tool 1: ckan_query ───────────────────────────────────────────
257
+ /**
258
+ * Query rows from an allowlisted CKAN datastore resource. The workhorse: reaches
259
+ * every datastore-active spend/checkbook/procurement/vendor/certification table on
260
+ * any allowlisted portal. Records pass through verbatim (values are typed per
261
+ * result.fields[].type).
262
+ *
263
+ * HONESTY: CKAN's envelope carries a real `result.total`. The DEFAULT path is an
264
+ * EXACT total (`total_was_estimated:false`) → exact totalAvailable + exact
265
+ * hasMore. The rare ESTIMATED path (`total_was_estimated:true`) is defused per
266
+ * ADR-0006 B1: totalAvailable:null + hasMore-by-page-fullness + totalIsEstimated +
267
+ * an estimate note (the estimate can be above OR below the truth, so it is NOT a
268
+ * lower bound and MUST NOT drive pagination). A genuine-empty (records:[],
269
+ * total:0) → complete:true/total:0; success:false / a missing-or-non-array
270
+ * `result.records` / a non-number `result.total` → THROWS (never a fake empty).
271
+ */
272
+ export async function query(args: {
273
+ host: CkanHost;
274
+ resourceId: string;
275
+ q?: string;
276
+ filters?: Record<string, string | number | Array<string | number>>;
277
+ sort?: string;
278
+ limit?: number;
279
+ offset?: number;
280
+ }): Promise<MetaBundle> {
281
+ const limit = args.limit ?? 100;
282
+ const offset = args.offset ?? 0;
283
+
284
+ const params = new URLSearchParams();
285
+ params.set("limit", String(limit));
286
+ params.set("offset", String(offset));
287
+ if (args.q) params.set("q", args.q);
288
+ if (args.sort) params.set("sort", args.sort);
289
+ // Q3 — `filters` is a constrained object WE serialize; never a caller-supplied
290
+ // raw JSON string. A bad field still surfaces as an upstream 409 → invalid_input.
291
+ if (args.filters !== undefined) params.set("filters", JSON.stringify(args.filters));
292
+
293
+ const body = await getCkanDatastore(args.host, args.resourceId, params);
294
+ const b = (body ?? {}) as {
295
+ success?: unknown;
296
+ error?: unknown;
297
+ result?: {
298
+ records?: unknown;
299
+ total?: unknown;
300
+ total_was_estimated?: unknown;
301
+ fields?: unknown;
302
+ };
303
+ };
304
+
305
+ // ── Shape guards (ORDER matters). ──
306
+ // (1) success:false (even on HTTP 200) → taxonomy-classified throw (m3), NEVER
307
+ // a fake empty.
308
+ if (b.success === false) throwCkanApiError(args.host, b.error);
309
+ // (2) result / records must be an array (m5) — a missing result or a
310
+ // string/null `records` is drift, never `[]`.
311
+ if (!b.result || !Array.isArray(b.result.records)) {
312
+ throw driftError(
313
+ "ckan:" + args.host,
314
+ `ckan:${args.host} datastore_search returned an unexpected shape (result.records must be an array).`,
315
+ );
316
+ }
317
+ // (3) a PRESENT `total` must be a number (m6) — num() cannot distinguish a
318
+ // non-number total from an absent one, so typeof-check BEFORE num().
319
+ if (b.result.total !== undefined && typeof b.result.total !== "number") {
320
+ throw driftError(
321
+ "ckan:" + args.host,
322
+ `ckan:${args.host} datastore_search returned a non-number result.total — treating as schema drift.`,
323
+ );
324
+ }
325
+
326
+ const records = b.result.records as CkanRecord[];
327
+ const fields = Array.isArray(b.result.fields) ? b.result.fields : [];
328
+ const returned = records.length;
329
+ const estimated = b.result.total_was_estimated === true;
330
+
331
+ const filtersApplied: string[] = [];
332
+ if (args.q) filtersApplied.push("q");
333
+ if (args.filters !== undefined) filtersApplied.push("filters");
334
+ if (args.sort) filtersApplied.push("sort");
335
+
336
+ const notes: string[] = [VALUE_TYPING_NOTE];
337
+
338
+ let totalAvailable: number | null;
339
+ let hasMore: boolean;
340
+ let totalIsEstimated: boolean | undefined;
341
+
342
+ if (estimated) {
343
+ // B1 — an ESTIMATE must NOT drive pagination. Pass null (so buildMeta derives
344
+ // truncated/complete from page-fullness alone), paginate by page-fullness, and
345
+ // disclose the estimate value + flag. The empty-trailing-page case (returned 0
346
+ // < limit) → hasMore:false → complete:true (the anti-livelock guard).
347
+ totalAvailable = null;
348
+ hasMore = returned >= limit;
349
+ totalIsEstimated = true;
350
+ const estValue = num(b.result.total);
351
+ notes.push(
352
+ `totalAvailable is withheld because the upstream reported it as a server-reported ESTIMATE${estValue !== null ? ` ~${estValue}` : ""}; it is not exact and may be above OR below the true count — paginate by page-fullness (hasMore is inferred from whether the page filled), not by this estimate.`,
353
+ );
354
+ } else {
355
+ // EXACT total (the default). B2 hedge: if `total` is somehow absent (a host
356
+ // running include_total:false), fall back to page-fullness so an unknown
357
+ // total never lies complete:true.
358
+ totalAvailable = num(b.result.total);
359
+ hasMore =
360
+ totalAvailable !== null
361
+ ? offset + returned < totalAvailable
362
+ : returned >= limit;
363
+ if (totalAvailable === null) {
364
+ notes.push(
365
+ "The upstream did not report result.total; completeness is inferred from page-fullness (CKAN returns a short page only when the result set is exhausted).",
366
+ );
367
+ }
368
+ }
369
+ const nextOffset = hasMore ? offset + returned : null;
370
+
371
+ const meta: Partial<ResponseMeta> = {
372
+ source: `${args.host} ${SOURCE_SUFFIX}`,
373
+ keylessMode: true,
374
+ returned,
375
+ totalAvailable,
376
+ filtersApplied,
377
+ filtersDropped: [],
378
+ fieldsUnavailable: [],
379
+ pagination: { offset, limit, hasMore, nextOffset },
380
+ notes,
381
+ };
382
+ if (totalIsEstimated !== undefined) meta.totalIsEstimated = totalIsEstimated;
383
+
384
+ return withMeta(
385
+ { host: args.host, resourceId: args.resourceId, fields, records },
386
+ meta,
387
+ );
388
+ }
389
+
390
+ // ─── Tool 2: ckan_discover_datasets ───────────────────────────────
391
+ export type CkanDiscoveredResource = {
392
+ resourceId: string | null;
393
+ name: string | null;
394
+ datasetTitle: string | null;
395
+ format: string | null;
396
+ datastoreActive: boolean;
397
+ };
398
+
399
+ /** Flatten one package_search `results[]` package into its resource rows. */
400
+ function mapPackageResources(pkg: unknown): CkanDiscoveredResource[] {
401
+ const p = (pkg ?? {}) as { title?: unknown; resources?: unknown };
402
+ const datasetTitle = str(p.title);
403
+ const resources = Array.isArray(p.resources) ? p.resources : [];
404
+ return resources.map((res) => {
405
+ const r = (res ?? {}) as {
406
+ id?: unknown;
407
+ name?: unknown;
408
+ format?: unknown;
409
+ datastore_active?: unknown;
410
+ };
411
+ return {
412
+ resourceId: str(r.id),
413
+ name: str(r.name),
414
+ datasetTitle,
415
+ format: str(r.format),
416
+ // Only a queryable (datastore-active) resource can feed ckan_query.
417
+ datastoreActive: r.datastore_active === true,
418
+ };
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Discover datastore resource ids via CKAN `package_search` (memoized ~10 min).
424
+ * Returns per-RESOURCE rows `[{ resourceId, name, datasetTitle, format,
425
+ * datastoreActive }]` + `totalAvailable = result.count` (the DATASET match count).
426
+ * Feed a `datastoreActive:true` resource's `resourceId` to ckan_query.
427
+ *
428
+ * m6 — this is the catalog's PRIMARY response: a non-number `result.count` is a
429
+ * hard schema_drift throw (nothing valid to return), never a fabricated total —
430
+ * INSIDE the memoize callback so a bad shape is never cached as a success
431
+ * (ADR-0005 v2 test-2 rule).
432
+ */
433
+ export async function discoverDatasets(args: {
434
+ host: CkanHost;
435
+ q: string;
436
+ limit?: number;
437
+ }): Promise<MetaBundle> {
438
+ const limit = args.limit ?? 20;
439
+ const params = new URLSearchParams();
440
+ params.set("q", args.q);
441
+ params.set("rows", String(limit));
442
+
443
+ const key = `ckan:package_search:${args.host}:${args.q}:${limit}`;
444
+ const { totalAvailable, results } = await memoize(
445
+ key,
446
+ async () => {
447
+ const body = await getCkanPackageSearch(args.host, params);
448
+ const b = (body ?? {}) as {
449
+ success?: unknown;
450
+ error?: unknown;
451
+ result?: { count?: unknown; results?: unknown };
452
+ };
453
+ if (b.success === false) throwCkanApiError(args.host, b.error);
454
+ if (!b.result) {
455
+ throw driftError(
456
+ "ckan:" + args.host,
457
+ `ckan:${args.host} package_search returned an unexpected shape (result missing).`,
458
+ );
459
+ }
460
+ // m6 — a non-number count is drift on the PRIMARY response (contrast a
461
+ // best-effort secondary). Check stays INSIDE memoize so it is never cached.
462
+ if (typeof b.result.count !== "number") {
463
+ throw driftError(
464
+ "ckan:" + args.host,
465
+ `ckan:${args.host} package_search returned a non-number result.count — treating as schema drift.`,
466
+ );
467
+ }
468
+ const total = num(b.result.count);
469
+ const packages = Array.isArray(b.result.results) ? b.result.results : [];
470
+ const rows = packages.flatMap(mapPackageResources);
471
+ return { totalAvailable: total, results: rows };
472
+ },
473
+ 10 * 60 * 1000,
474
+ );
475
+
476
+ const returned = results.length;
477
+ const notes: string[] = [
478
+ `package_search over ${args.host} (rows=${limit}). Feed a datastoreActive:true result's resourceId to ckan_query; a datastoreActive:false resource is a raw file blob (CSV/PDF/…) NOT in the datastore and is NOT queryable.`,
479
+ "totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned may differ from totalAvailable.",
480
+ ];
481
+
482
+ return withMeta(
483
+ { host: args.host, query: args.q, results },
484
+ {
485
+ source: `${args.host} package_search ${SOURCE_SUFFIX}`,
486
+ keylessMode: true,
487
+ returned,
488
+ totalAvailable,
489
+ filtersApplied: ["q"],
490
+ filtersDropped: [],
491
+ fieldsUnavailable: [],
492
+ notes,
493
+ } satisfies Partial<ResponseMeta>,
494
+ );
495
+ }