@cliwant/mcp-sam-gov 0.3.0 → 1.1.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 (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +49 -16
  3. package/README.ko.md +49 -16
  4. package/README.md +255 -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-economic.d.ts +93 -0
  18. package/dist/census-economic.d.ts.map +1 -0
  19. package/dist/census-economic.js +355 -0
  20. package/dist/census-economic.js.map +1 -0
  21. package/dist/census.d.ts +182 -0
  22. package/dist/census.d.ts.map +1 -0
  23. package/dist/census.js +576 -0
  24. package/dist/census.js.map +1 -0
  25. package/dist/ckan.d.ts +141 -0
  26. package/dist/ckan.d.ts.map +1 -0
  27. package/dist/ckan.js +393 -0
  28. package/dist/ckan.js.map +1 -0
  29. package/dist/clinicaltrials.d.ts +180 -0
  30. package/dist/clinicaltrials.d.ts.map +1 -0
  31. package/dist/clinicaltrials.js +730 -0
  32. package/dist/clinicaltrials.js.map +1 -0
  33. package/dist/cms.d.ts +140 -0
  34. package/dist/cms.d.ts.map +1 -0
  35. package/dist/cms.js +482 -0
  36. package/dist/cms.js.map +1 -0
  37. package/dist/coerce.d.ts +32 -0
  38. package/dist/coerce.d.ts.map +1 -0
  39. package/dist/coerce.js +50 -0
  40. package/dist/coerce.js.map +1 -0
  41. package/dist/datagov-catalog.d.ts +84 -0
  42. package/dist/datagov-catalog.d.ts.map +1 -0
  43. package/dist/datagov-catalog.js +233 -0
  44. package/dist/datagov-catalog.js.map +1 -0
  45. package/dist/datagov.d.ts +146 -0
  46. package/dist/datagov.d.ts.map +1 -0
  47. package/dist/datagov.js +689 -0
  48. package/dist/datagov.js.map +1 -0
  49. package/dist/datagovKey.d.ts +36 -0
  50. package/dist/datagovKey.d.ts.map +1 -0
  51. package/dist/datagovKey.js +60 -0
  52. package/dist/datagovKey.js.map +1 -0
  53. package/dist/datasource.d.ts +313 -0
  54. package/dist/datasource.d.ts.map +1 -0
  55. package/dist/datasource.js +551 -0
  56. package/dist/datasource.js.map +1 -0
  57. package/dist/disclosure.d.ts +55 -0
  58. package/dist/disclosure.d.ts.map +1 -0
  59. package/dist/disclosure.js +57 -0
  60. package/dist/disclosure.js.map +1 -0
  61. package/dist/ecfr.d.ts +4 -2
  62. package/dist/ecfr.d.ts.map +1 -1
  63. package/dist/ecfr.js +74 -1
  64. package/dist/ecfr.js.map +1 -1
  65. package/dist/echo.d.ts +143 -0
  66. package/dist/echo.d.ts.map +1 -0
  67. package/dist/echo.js +424 -0
  68. package/dist/echo.js.map +1 -0
  69. package/dist/edgar.d.ts +377 -0
  70. package/dist/edgar.d.ts.map +1 -0
  71. package/dist/edgar.js +2418 -0
  72. package/dist/edgar.js.map +1 -0
  73. package/dist/errors.d.ts +26 -0
  74. package/dist/errors.d.ts.map +1 -1
  75. package/dist/errors.js +69 -1
  76. package/dist/errors.js.map +1 -1
  77. package/dist/fac.d.ts +180 -0
  78. package/dist/fac.d.ts.map +1 -0
  79. package/dist/fac.js +416 -0
  80. package/dist/fac.js.map +1 -0
  81. package/dist/far.d.ts +170 -0
  82. package/dist/far.d.ts.map +1 -0
  83. package/dist/far.js +804 -0
  84. package/dist/far.js.map +1 -0
  85. package/dist/fdic.d.ts +599 -0
  86. package/dist/fdic.d.ts.map +1 -0
  87. package/dist/fdic.js +1624 -0
  88. package/dist/fdic.js.map +1 -0
  89. package/dist/federal-register.d.ts +139 -2
  90. package/dist/federal-register.d.ts.map +1 -1
  91. package/dist/federal-register.js +419 -3
  92. package/dist/federal-register.js.map +1 -1
  93. package/dist/fema.d.ts +181 -0
  94. package/dist/fema.d.ts.map +1 -0
  95. package/dist/fema.js +436 -0
  96. package/dist/fema.js.map +1 -0
  97. package/dist/fpds.d.ts +108 -0
  98. package/dist/fpds.d.ts.map +1 -0
  99. package/dist/fpds.js +519 -0
  100. package/dist/fpds.js.map +1 -0
  101. package/dist/fred.d.ts +108 -0
  102. package/dist/fred.d.ts.map +1 -0
  103. package/dist/fred.js +373 -0
  104. package/dist/fred.js.map +1 -0
  105. package/dist/gao.d.ts +64 -0
  106. package/dist/gao.d.ts.map +1 -0
  107. package/dist/gao.js +640 -0
  108. package/dist/gao.js.map +1 -0
  109. package/dist/govinfo.d.ts +111 -0
  110. package/dist/govinfo.d.ts.map +1 -0
  111. package/dist/govinfo.js +422 -0
  112. package/dist/govinfo.js.map +1 -0
  113. package/dist/grants.d.ts +27 -4
  114. package/dist/grants.d.ts.map +1 -1
  115. package/dist/grants.js +111 -6
  116. package/dist/grants.js.map +1 -1
  117. package/dist/gsa-csv.d.ts +249 -0
  118. package/dist/gsa-csv.d.ts.map +1 -0
  119. package/dist/gsa-csv.js +784 -0
  120. package/dist/gsa-csv.js.map +1 -0
  121. package/dist/integrity.d.ts +212 -0
  122. package/dist/integrity.d.ts.map +1 -0
  123. package/dist/integrity.js +707 -0
  124. package/dist/integrity.js.map +1 -0
  125. package/dist/keys.d.ts +83 -0
  126. package/dist/keys.d.ts.map +1 -0
  127. package/dist/keys.js +173 -0
  128. package/dist/keys.js.map +1 -0
  129. package/dist/meta.d.ts +165 -0
  130. package/dist/meta.d.ts.map +1 -0
  131. package/dist/meta.js +162 -0
  132. package/dist/meta.js.map +1 -0
  133. package/dist/nih.d.ts +117 -0
  134. package/dist/nih.d.ts.map +1 -0
  135. package/dist/nih.js +291 -0
  136. package/dist/nih.js.map +1 -0
  137. package/dist/nppes.d.ts +157 -0
  138. package/dist/nppes.d.ts.map +1 -0
  139. package/dist/nppes.js +648 -0
  140. package/dist/nppes.js.map +1 -0
  141. package/dist/nsf.d.ts +176 -0
  142. package/dist/nsf.d.ts.map +1 -0
  143. package/dist/nsf.js +554 -0
  144. package/dist/nsf.js.map +1 -0
  145. package/dist/nvd.d.ts +176 -0
  146. package/dist/nvd.d.ts.map +1 -0
  147. package/dist/nvd.js +912 -0
  148. package/dist/nvd.js.map +1 -0
  149. package/dist/ofac.d.ts +205 -0
  150. package/dist/ofac.d.ts.map +1 -0
  151. package/dist/ofac.js +919 -0
  152. package/dist/ofac.js.map +1 -0
  153. package/dist/pricing.d.ts +110 -0
  154. package/dist/pricing.d.ts.map +1 -0
  155. package/dist/pricing.js +843 -0
  156. package/dist/pricing.js.map +1 -0
  157. package/dist/sam-gov/client.d.ts +60 -2
  158. package/dist/sam-gov/client.d.ts.map +1 -1
  159. package/dist/sam-gov/client.js +320 -54
  160. package/dist/sam-gov/client.js.map +1 -1
  161. package/dist/sam-gov/index.d.ts +1 -1
  162. package/dist/sam-gov/index.d.ts.map +1 -1
  163. package/dist/sam-gov/index.js +1 -1
  164. package/dist/sam-gov/index.js.map +1 -1
  165. package/dist/sam-gov/types.d.ts +24 -0
  166. package/dist/sam-gov/types.d.ts.map +1 -1
  167. package/dist/sba.d.ts +72 -0
  168. package/dist/sba.d.ts.map +1 -0
  169. package/dist/sba.js +281 -0
  170. package/dist/sba.js.map +1 -0
  171. package/dist/server.d.ts +14 -2
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +4032 -297
  174. package/dist/server.js.map +1 -1
  175. package/dist/snapshot.d.ts +115 -0
  176. package/dist/snapshot.d.ts.map +1 -0
  177. package/dist/snapshot.js +175 -0
  178. package/dist/snapshot.js.map +1 -0
  179. package/dist/socrata.d.ts +157 -0
  180. package/dist/socrata.d.ts.map +1 -0
  181. package/dist/socrata.js +448 -0
  182. package/dist/socrata.js.map +1 -0
  183. package/dist/treasury.d.ts +143 -0
  184. package/dist/treasury.d.ts.map +1 -0
  185. package/dist/treasury.js +436 -0
  186. package/dist/treasury.js.map +1 -0
  187. package/dist/usaspending.d.ts +260 -65
  188. package/dist/usaspending.d.ts.map +1 -1
  189. package/dist/usaspending.js +1629 -201
  190. package/dist/usaspending.js.map +1 -1
  191. package/dist/usitc.d.ts +142 -0
  192. package/dist/usitc.d.ts.map +1 -0
  193. package/dist/usitc.js +339 -0
  194. package/dist/usitc.js.map +1 -0
  195. package/package.json +24 -2
  196. package/src/attachments.ts +652 -0
  197. package/src/bls.ts +1943 -0
  198. package/src/cache.ts +5 -0
  199. package/src/census-economic.ts +425 -0
  200. package/src/census.ts +735 -0
  201. package/src/ckan.ts +495 -0
  202. package/src/clinicaltrials.ts +923 -0
  203. package/src/cms.ts +634 -0
  204. package/src/coerce.ts +47 -0
  205. package/src/datagov-catalog.ts +296 -0
  206. package/src/datagov.ts +907 -0
  207. package/src/datagovKey.ts +68 -0
  208. package/src/datasource.ts +721 -0
  209. package/src/disclosure.ts +61 -0
  210. package/src/ecfr.ts +98 -1
  211. package/src/echo.ts +496 -0
  212. package/src/edgar.ts +3014 -0
  213. package/src/errors.ts +84 -1
  214. package/src/fac.ts +529 -0
  215. package/src/far.ts +1007 -0
  216. package/src/fdic.ts +2052 -0
  217. package/src/federal-register.ts +512 -3
  218. package/src/fema.ts +541 -0
  219. package/src/fpds.ts +620 -0
  220. package/src/fred.ts +464 -0
  221. package/src/gao.ts +744 -0
  222. package/src/govinfo.ts +497 -0
  223. package/src/grants.ts +139 -7
  224. package/src/gsa-csv.ts +992 -0
  225. package/src/integrity.ts +928 -0
  226. package/src/keys.ts +216 -0
  227. package/src/meta.ts +292 -0
  228. package/src/nih.ts +375 -0
  229. package/src/nppes.ts +834 -0
  230. package/src/nsf.ts +706 -0
  231. package/src/nvd.ts +1124 -0
  232. package/src/ofac.ts +1166 -0
  233. package/src/pricing.ts +1075 -0
  234. package/src/sam-gov/client.ts +345 -63
  235. package/src/sam-gov/index.ts +5 -1
  236. package/src/sam-gov/types.ts +22 -0
  237. package/src/sba.ts +357 -0
  238. package/src/server.ts +4717 -328
  239. package/src/snapshot.ts +223 -0
  240. package/src/socrata.ts +532 -0
  241. package/src/treasury.ts +575 -0
  242. package/src/usaspending.ts +1946 -214
  243. package/src/usitc.ts +420 -0
package/src/socrata.ts ADDED
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Socrata / SODA — keyless open data for state / local (SLED) + E-rate portals.
3
+ *
4
+ * First SLED source (ADR-0004); 3rd consumer of the fetch/map/meta shape after
5
+ * treasury.ts / edgar.ts. Fully PUBLIC, KEYLESS. ONE connector reaches ~a dozen
6
+ * US state portals + E-rate on the IDENTICAL SODA JSON API — swap hostname + a
7
+ * 4x4 dataset id. Hosts are a CURATED allowlist (see SSRF below); the caller
8
+ * never supplies a free host or a free path.
9
+ * Row query: https://{domain}/resource/{4x4}.json?$select=…&$where=…&$limit=…
10
+ * Catalog: https://api.us.socrata.com/api/catalog/v1?domains={domain}&q=…
11
+ *
12
+ * Three layers (mirror treasury.ts / edgar.ts):
13
+ * fetch — `getSocrataResource` / `getCatalog`: SSRF-guard, build the URL,
14
+ * set `redirect:"error"` + the optional app-token header, reuse
15
+ * errors.ts retry/timeout/taxonomy. Return the parsed JSON.
16
+ * map — rows pass through mostly verbatim (already JSON; value fields are
17
+ * strings). `num(x)` → number|null (NEVER 0 for absent) for the count
18
+ * coercion. `mapCatalogRow` for discovery.
19
+ * meta — `withMeta(...)`: hands totalAvailable/returned/pagination/notes to
20
+ * meta.ts's `buildMeta`, which DERIVES complete/truncated.
21
+ *
22
+ * ★ SSRF GUARD (policy① — the central design risk). The `domain` param is an
23
+ * arbitrary-host vector, so it is a curated allowlist-enum + a fixed path (this
24
+ * mirrors the treasury `dataset` enum + SamAttachmentUrl fixed-base). Validation
25
+ * order: (1) domain ∈ SOCRATA_DOMAINS (a Zod enum at the server boundary, AND a
26
+ * belt-and-suspenders Set check here) → else invalid_input BEFORE any fetch;
27
+ * (2) datasetId matches /^[a-z0-9]{4}-[a-z0-9]{4}$/ AND is exactly 9 chars (M2 —
28
+ * `.length(9)` on the RAW string rejects a trailing `\n` that the regex `$` would
29
+ * otherwise admit; the Zod schema deliberately does NOT `.trim()`, since Zod
30
+ * trims BEFORE `.length(9)` and would strip the `\n` to a valid id — defeating
31
+ * the guard; see server.ts SocrataQueryInput);
32
+ * (3) construct the URL, then ASSERT `new URL(built).hostname === domain` and
33
+ * `protocol === "https:"`; (4) SoQL params go through URLSearchParams — encoded
34
+ * values, no host-alteration surface (bad SoQL is upstream-validated: 400 →
35
+ * invalid_input, surfaced, never silent). Adding a domain later = an allowlist
36
+ * SOURCE edit + a live `$limit=1` verification + a test note — NEVER a free
37
+ * runtime param.
38
+ *
39
+ * B1 (SSRF, redirect) — the pre-fetch `hostname === domain` assertion runs
40
+ * BEFORE fetch, which defaults to `redirect:"follow"`; a 3xx from an allowlisted
41
+ * host (migration / DNS-hijack / decommissioned-then-reused domain) would be
42
+ * followed to an off-allowlist target the pre-check never sees. `attachments.ts`
43
+ * closes this with a post-redirect `finalHost` check (~L484–515). SODA
44
+ * `/resource/{4x4}.json` and the catalog are direct-JSON endpoints, so a 3xx is
45
+ * anomalous → we set `redirect:"error"` in the init passed to fetchWithRetry for
46
+ * EVERY socrata + catalog fetch (row, count companion, catalog). errors.ts's
47
+ * fetchWithRetry calls `fetch(url, init)` directly (L139), forwarding `init`
48
+ * verbatim, so `redirect:"error"` IS honored (confirmed) — a redirect makes
49
+ * fetch throw, is never followed, and its body is never read back.
50
+ *
51
+ * B2 (honesty, hasMore) — SODA's row response has NO total, so `hasMore` must
52
+ * NOT short-circuit to false on an unknown total (that would lie complete:true
53
+ * on a full page and stop an agent mid-dataset). Formula:
54
+ * hasMore = totalAvailable !== null ? (offset + returned < totalAvailable)
55
+ * : (returned >= limit)
56
+ * SODA returns fewer than $limit ONLY when the result set is exhausted, so
57
+ * `returned >= limit` on an unknown total is the correct hedge (→ truncated).
58
+ *
59
+ * m3 (honesty, catalog drift) — `socrata_discover_datasets` is the catalog's
60
+ * PRIMARY response; if `typeof resultSetSize !== "number"` → hard schema_drift
61
+ * throw (nothing valid to return), then `totalAvailable = num(resultSetSize)`.
62
+ *
63
+ * m4 (honesty, count companion) — the count(*) companion is a best-effort
64
+ * enrichment of an ALREADY-SUCCESSFUL row query: ANY count failure → degrade to
65
+ * totalAvailable:null and STILL return the rows (never lose good data, never
66
+ * fake complete). The note distinguishes a transient fetch throw from a 200 +
67
+ * wrong shape (possible upstream API change). A hard schema_drift throw is
68
+ * reserved for a PRIMARY query (rows / catalog), NEVER the secondary count.
69
+ *
70
+ * ALLOWLIST — LIVE-VERIFIED 2026-07-10 (each carries a real sample 4x4). All
71
+ * `.gov` except `opendata.usac.org`:
72
+ * m6 — `opendata.usac.org` is a `.org` (USAC, a Congress-designated non-profit;
73
+ * E-rate). It is on the periodic re-verification checklist. NOTE: the
74
+ * federated discovery catalog (api.us.socrata.com) does NOT index USAC
75
+ * (returns resultSetSize 0), so `socrata_discover_datasets` will not
76
+ * surface it — but `socrata_query` works against it with a known 4x4
77
+ * (live: opendata.usac.org/resource/avi8-svp9.json → 200 bare array).
78
+ * M1 — MA is DROPPED from slice 1: `cthru.data.socrata.com` is a commercial
79
+ * vendor host (Tyler Technologies `.socrata.com`, not gov-controlled) and
80
+ * no `.gov` MA Socrata host verifies (`data.mass.gov` → the catalog
81
+ * answers "Domain not found"). Keep the allowlist all-`.gov` + the one
82
+ * `.org` USAC; revisit MA when a `data.mass.gov` (or documented
83
+ * trust-boundary caveat) verifies.
84
+ * CHURN-EXCLUDED — VA (→CKAN), IA (left Socrata → SODA 404s), MI (→SIGMA), and
85
+ * IL (`data.illinois.gov` — live 2026-07-10 the catalog returns 0
86
+ * datasets; its ids are "story" types whose resource endpoints 403/404,
87
+ * i.e. no queryable SODA table) are NOT in the allowlist.
88
+ *
89
+ * TOOL LEANNESS — slice 1 ships 2 core tools (socrata_query + socrata_discover_
90
+ * datasets). The optional `socrata_state_datasets` (a static state→portal→4x4
91
+ * map) is NOT shipped: a hand-curated list of specific 4x4 ids is precisely the
92
+ * staleness/honesty liability the churn exclusion warns about (a moved dataset
93
+ * silently 404s), and discovery is already served — always FRESH — by the live
94
+ * catalog in socrata_discover_datasets, while the state→domain mapping is already
95
+ * visible in the `domain` enum. So it does not earn its schema cost. (Documented
96
+ * for the reviewer.)
97
+ *
98
+ * Keyless-first — keyless works everywhere above (all live-verified without a
99
+ * token); a FREE `X-App-Token` only lifts the shared-IP 429 limits. Optional
100
+ * `SOCRATA_APP_TOKEN` → the `X-App-Token` header only; never required, never
101
+ * logged, never placed in `_meta` or an error (see m7 at the fetch call site).
102
+ */
103
+
104
+ import { ToolErrorCarrier } from "./errors.js";
105
+ import { getJson, driftError } from "./datasource.js";
106
+ import { num, str } from "./coerce.js";
107
+ import { memoize } from "./cache.js";
108
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
109
+
110
+ // Re-export the shared honesty coercion (single audited copy now lives in
111
+ // ./coerce.js — ADR-0005 v2 FIX-C) so existing importers and the fault suite's
112
+ // num-parity guard keep resolving `num` from this module.
113
+ export { num };
114
+
115
+ // ─── Curated allowlist (SSRF core) ────────────────────────────────
116
+ // A frozen list of live-verified (2026-07-10) Socrata SODA hosts. The Zod
117
+ // `domain` enum in server.ts is built FROM this array (single source of truth),
118
+ // and getSocrataResource re-checks membership (belt-and-suspenders). Each entry
119
+ // carries a real sample 4x4 confirmed to return a 200 bare array.
120
+ export const SOCRATA_DOMAINS = [
121
+ "data.ny.gov", // NY — e.g. kwxv-fwze
122
+ "data.colorado.gov", // CO — e.g. 4ykn-tg5h
123
+ "data.ct.gov", // CT — e.g. 28fr-iqnx
124
+ "data.texas.gov", // TX — e.g. 54pj-3dxy
125
+ "data.wa.gov", // WA — e.g. qxh8-f4bd
126
+ "opendata.maryland.gov", // MD — e.g. 2ir4-626w
127
+ "data.vermont.gov", // VT — e.g. jgqy-2smf
128
+ "data.nj.gov", // NJ — e.g. 44xg-bswk
129
+ "data.oregon.gov", // OR — e.g. tckn-sxa6
130
+ "data.pa.gov", // PA — e.g. mcba-yywm
131
+ "data.mo.gov", // MO — e.g. gfq7-aa86
132
+ "data.delaware.gov", // DE — e.g. 5zy2-grhr
133
+ "opendata.usac.org", // USAC E-rate (.org, m6) — e.g. avi8-svp9
134
+ ] as const;
135
+
136
+ export type SocrataDomain = (typeof SOCRATA_DOMAINS)[number];
137
+
138
+ const SOCRATA_DOMAIN_SET: ReadonlySet<string> = new Set(SOCRATA_DOMAINS);
139
+
140
+ const CATALOG_HOST = "api.us.socrata.com";
141
+ const CATALOG_URL = `https://${CATALOG_HOST}/api/catalog/v1`;
142
+
143
+ // A valid Socrata 4x4 is EXACTLY 9 chars: 4 lowercase-alnum, a hyphen, 4 more.
144
+ // `.length === 9` (not just the regex) rejects a trailing `\n` (M2) that the
145
+ // regex `$` alone would admit ("abcd-1234\n" passes /…$/ in JS).
146
+ const DATASET_ID_RE = /^[a-z0-9]{4}-[a-z0-9]{4}$/;
147
+
148
+ // D2 — an AGGREGATE $select projection. Matches a SoQL aggregate function
149
+ // (count/sum/avg/min/max) applied via `fn(` — the `\b…\s*\(` shape avoids false
150
+ // hits on column names like `max_temperature` (no paren) or `xmax(` (no word
151
+ // boundary) — OR an explicit `group by`/`$group`. Case-insensitive. When the
152
+ // caller's own $select is aggregate, the count(*) companion is skipped (its
153
+ // raw-row total would be false for aggregate result rows). See `query`.
154
+ const AGGREGATE_SELECT_RE =
155
+ /\b(?:count|sum|avg|min|max)\s*\(|\bgroup\s+by\b|\$group\b/i;
156
+
157
+ // ─── HONESTY-CRITICAL coercions (null, never 0, for absent) ───────
158
+ // `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
159
+ // (imported above, `num` re-exported): null/undefined, the literal "null",
160
+ // ""/whitespace (Number("") is 0!), and "(-)"/"-" all become null (never 0).
161
+
162
+ /**
163
+ * The optional app-token header (keyless-first). Present ONLY when
164
+ * SOCRATA_APP_TOKEN is set; the value is never logged / never in `_meta`.
165
+ */
166
+ function appTokenHeader(): Record<string, string> {
167
+ const t = process.env.SOCRATA_APP_TOKEN;
168
+ return t ? { "X-App-Token": t } : {};
169
+ }
170
+
171
+ /** true iff an app token is configured (for the `_meta` note — never the value). */
172
+ function appTokenPresent(): boolean {
173
+ return !!process.env.SOCRATA_APP_TOKEN;
174
+ }
175
+
176
+ // ─── fetch layer ──────────────────────────────────────────────────
177
+ export type SocrataRow = Record<string, unknown>;
178
+
179
+ /**
180
+ * GET one SODA resource. SSRF guard (belt-and-suspenders behind the server's
181
+ * Zod enum+regex): domain ∈ allowlist, datasetId is a 9-char 4x4, and the
182
+ * CONSTRUCTED URL's hostname === domain (protocol https). Sets `redirect:"error"`
183
+ * (B1) + the optional app-token header, and reuses errors.ts retry/timeout/
184
+ * taxonomy (429 → rate_limited retryable; 5xx → upstream_unavailable; 404 →
185
+ * not_found; 400 → invalid_input). Returns the parsed JSON (unknown; callers
186
+ * validate the shape).
187
+ */
188
+ async function getSocrataResource(
189
+ domain: string,
190
+ datasetId: string,
191
+ params: URLSearchParams,
192
+ ): Promise<unknown> {
193
+ if (!SOCRATA_DOMAIN_SET.has(domain)) {
194
+ throw new ToolErrorCarrier({
195
+ kind: "invalid_input",
196
+ message: `Socrata domain ${JSON.stringify(domain)} is not on the curated allowlist. Allowed: ${SOCRATA_DOMAINS.join(", ")}.`,
197
+ retryable: false,
198
+ });
199
+ }
200
+ if (datasetId.length !== 9 || !DATASET_ID_RE.test(datasetId)) {
201
+ throw new ToolErrorCarrier({
202
+ kind: "invalid_input",
203
+ message: `Invalid Socrata datasetId ${JSON.stringify(datasetId)} — expected a 4x4 id matching [a-z0-9]{4}-[a-z0-9]{4} (exactly 9 chars).`,
204
+ retryable: false,
205
+ });
206
+ }
207
+ const url = `https://${domain}/resource/${datasetId}.json?${params.toString()}`;
208
+ // Belt-and-suspenders: the FIXED path leaves only domain+datasetId to
209
+ // interpolate; assert the built URL cannot have been steered off-host.
210
+ const built = new URL(url);
211
+ if (built.hostname !== domain || built.protocol !== "https:") {
212
+ throw new ToolErrorCarrier({
213
+ kind: "invalid_input",
214
+ message: `Constructed Socrata URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted domain ${JSON.stringify(domain)} over https — refusing to fetch (SSRF safety).`,
215
+ retryable: false,
216
+ });
217
+ }
218
+ // Shared fetch envelope (ADR-0005): init === { headers, redirect, signal } —
219
+ // byte-identical to the prior hand-rolled fetch.
220
+ // B1 — redirect:"error": a 3xx off an allowlisted host is anomalous for a
221
+ // direct-JSON SODA endpoint; error out rather than silently follow it
222
+ // off-allowlist (cf. attachments.ts finalHost precedent).
223
+ // m7 — the label is host-only; it surfaces verbatim in
224
+ // ToolError.upstreamEndpoint to the MCP caller and must NEVER include the
225
+ // app-token value.
226
+ return getJson(url, {
227
+ label: "socrata:" + domain,
228
+ headers: appTokenHeader(),
229
+ redirect: "error",
230
+ });
231
+ }
232
+
233
+ /**
234
+ * GET the discovery catalog (fixed host api.us.socrata.com; `domains` bound to
235
+ * the allowlist enum by the caller). Same redirect/token/timeout policy.
236
+ */
237
+ async function getCatalog(params: URLSearchParams): Promise<unknown> {
238
+ const url = `${CATALOG_URL}?${params.toString()}`;
239
+ const built = new URL(url);
240
+ if (built.hostname !== CATALOG_HOST || built.protocol !== "https:") {
241
+ throw new ToolErrorCarrier({
242
+ kind: "invalid_input",
243
+ message: `Constructed Socrata catalog URL host ${JSON.stringify(built.hostname)} does not match ${CATALOG_HOST} over https — refusing to fetch (SSRF safety).`,
244
+ retryable: false,
245
+ });
246
+ }
247
+ // Shared fetch envelope (ADR-0005) — same redirect:"error" (B1) + host-only
248
+ // label (m7, never the token) as getSocrataResource.
249
+ return getJson(url, {
250
+ label: "socrata:catalog",
251
+ headers: appTokenHeader(),
252
+ redirect: "error",
253
+ });
254
+ }
255
+
256
+ // ─── map + meta helpers ───────────────────────────────────────────
257
+ const STRING_COERCION_NOTE =
258
+ "Row value fields arrive as strings verbatim from SODA (e.g. \"13650.00\", \"1\") — parse client-side. A missing value is absent, never 0.";
259
+
260
+ const SOURCE_SUFFIX = "via Socrata SODA (keyless)";
261
+
262
+ /** The count(*) companion outcome (m4): a real total, or a null with a reason. */
263
+ type CountOutcome =
264
+ | { total: number; reason: "ok" }
265
+ | { total: null; reason: "transient" } // fetch threw (network/5xx/timeout)
266
+ | { total: null; reason: "drift" }; // HTTP 200 but not `[{count:"<num>"}]`
267
+
268
+ /**
269
+ * The count(*) companion (m4). Reuses the caller's `$where`/`$q`, drops
270
+ * `$select/$order/$limit/$offset`, sets `$select=count(*)`. BEST-EFFORT: ANY
271
+ * failure → total:null with a reason (transient throw vs 200-wrong-shape) — the
272
+ * caller keeps the rows and never fakes a total. A hard schema_drift throw is
273
+ * NEVER raised here (reserved for the PRIMARY row/catalog query).
274
+ */
275
+ async function fetchCount(
276
+ domain: string,
277
+ datasetId: string,
278
+ where?: string,
279
+ q?: string,
280
+ ): Promise<CountOutcome> {
281
+ const params = new URLSearchParams();
282
+ if (where) params.set("$where", where);
283
+ if (q) params.set("$q", q);
284
+ params.set("$select", "count(*)");
285
+ let body: unknown;
286
+ try {
287
+ body = await getSocrataResource(domain, datasetId, params);
288
+ } catch {
289
+ return { total: null, reason: "transient" };
290
+ }
291
+ // Expected shape: a single-row array [{ count: "<number-as-string>" }].
292
+ if (
293
+ Array.isArray(body) &&
294
+ body.length === 1 &&
295
+ body[0] != null &&
296
+ typeof body[0] === "object" &&
297
+ "count" in (body[0] as Record<string, unknown>)
298
+ ) {
299
+ const c = num((body[0] as Record<string, unknown>).count);
300
+ if (c !== null) return { total: c, reason: "ok" };
301
+ }
302
+ return { total: null, reason: "drift" };
303
+ }
304
+
305
+ // ─── Tool 1: socrata_query ────────────────────────────────────────
306
+ /**
307
+ * Query rows from an allowlisted Socrata SODA dataset. The workhorse: reaches
308
+ * every spend/checkbook/contract/vendor-payment/E-rate dataset on any
309
+ * allowlisted portal. Rows pass through verbatim (value fields are strings).
310
+ *
311
+ * HONESTY: SODA's row response carries NO total, so (default) a count(*)
312
+ * companion supplies `totalAvailable`; if it fails, the rows still return with
313
+ * `totalAvailable:null` + a disclosing note (§m4), and `hasMore` is inferred
314
+ * from the page fill (§B2 — never a false complete). Genuine-empty (`[]` + count
315
+ * 0) → honest complete:true/totalAvailable:0; an outage/5xx/timeout/400/404 on
316
+ * the ROW query THROWS (never a fake empty).
317
+ */
318
+ export async function query(args: {
319
+ domain: SocrataDomain;
320
+ datasetId: string;
321
+ select?: string;
322
+ where?: string;
323
+ order?: string;
324
+ q?: string;
325
+ limit?: number;
326
+ offset?: number;
327
+ withTotal?: boolean;
328
+ }): Promise<MetaBundle> {
329
+ const limit = args.limit ?? 100;
330
+ const offset = args.offset ?? 0;
331
+ const withTotal = args.withTotal ?? true;
332
+
333
+ // ── Primary row query (a shape violation here is a hard schema_drift). ──
334
+ const rowParams = new URLSearchParams();
335
+ if (args.select) rowParams.set("$select", args.select);
336
+ if (args.where) rowParams.set("$where", args.where);
337
+ if (args.order) rowParams.set("$order", args.order);
338
+ if (args.q) rowParams.set("$q", args.q);
339
+ rowParams.set("$limit", String(limit));
340
+ rowParams.set("$offset", String(offset));
341
+ const body = await getSocrataResource(args.domain, args.datasetId, rowParams);
342
+ if (!Array.isArray(body)) {
343
+ throw driftError(
344
+ "socrata:" + args.domain,
345
+ `socrata:${args.domain}/${args.datasetId} returned an unexpected shape (SODA /resource/{4x4}.json must be a JSON array of rows).`,
346
+ );
347
+ }
348
+ const rows = body as SocrataRow[];
349
+ const returned = rows.length;
350
+
351
+ // ── count(*) companion (best-effort; §m4). ──
352
+ // D2 — aggregate-projection guard. When the caller's OWN $select is an
353
+ // AGGREGATE projection (count/sum/avg/min/max, or a group-by), the result
354
+ // rows are AGGREGATES, not raw records — so a count(*) companion (which
355
+ // counts the RAW underlying rows) would report a FALSE total. E.g. a 1-row
356
+ // `$select=count(*)` result would get totalAvailable = raw-row-count,
357
+ // hasMore:true, nextOffset:1 → an agent pages forever over a 1-row result
358
+ // (a false-pagination livelock). When detected we SKIP the companion
359
+ // entirely, set totalAvailable:null (an aggregate has no meaningful raw-row
360
+ // total), and let hasMore fall out of PAGE-FULLNESS below (§B2), never a
361
+ // bogus total. A non-aggregate query keeps the exact-count behavior.
362
+ const isAggregateSelect =
363
+ args.select !== undefined && AGGREGATE_SELECT_RE.test(args.select);
364
+
365
+ let totalAvailable: number | null = null;
366
+ let countNote: string;
367
+ if (isAggregateSelect) {
368
+ countNote =
369
+ "The $select is an aggregate/group-by projection (count/sum/avg/min/max or group by), so its result rows are aggregates — an aggregate has no meaningful raw-row total. totalAvailable is null and pagination is page-fullness-based; the count(*) companion was NOT issued (it would count the RAW underlying rows and report a FALSE total).";
370
+ } else if (withTotal) {
371
+ const outcome = await fetchCount(args.domain, args.datasetId, args.where, args.q);
372
+ if (outcome.reason === "ok") {
373
+ totalAvailable = outcome.total;
374
+ countNote =
375
+ "totalAvailable was resolved via a count(*) companion query (SODA's row response carries no total).";
376
+ } else if (outcome.reason === "transient") {
377
+ countNote =
378
+ "SODA's row response carries no total; the count(*) companion failed (transient); total unknown — results may be truncated at $limit (page via $offset).";
379
+ } else {
380
+ countNote =
381
+ "SODA's row response carries no total; the count(*) companion returned an unexpected shape (possible upstream API change); total unknown — results may be truncated at $limit (page via $offset).";
382
+ }
383
+ } else {
384
+ countNote =
385
+ "withTotal:false — the count(*) companion was skipped; total unknown — results may be truncated at $limit (page via $offset).";
386
+ }
387
+
388
+ // ── B2 — hasMore MUST NOT short-circuit to false on an unknown total. ──
389
+ const hasMore =
390
+ totalAvailable !== null
391
+ ? offset + returned < totalAvailable
392
+ : returned >= limit;
393
+ const nextOffset = hasMore ? offset + returned : null;
394
+
395
+ const filtersApplied: string[] = [];
396
+ if (args.select) filtersApplied.push("select");
397
+ if (args.where) filtersApplied.push("where");
398
+ if (args.order) filtersApplied.push("order");
399
+ if (args.q) filtersApplied.push("q");
400
+
401
+ const notes: string[] = [
402
+ countNote,
403
+ `App token: ${appTokenPresent() ? "present (X-App-Token sent; value never logged)" : "absent (keyless; a free SOCRATA_APP_TOKEN lifts shared-IP 429 limits)"}.`,
404
+ STRING_COERCION_NOTE,
405
+ ];
406
+ if (totalAvailable === null && !hasMore) {
407
+ notes.push(
408
+ "totalAvailable is unknown but completeness is INFERRED from a short page (returned < $limit): SODA returns fewer than $limit only when the result set is exhausted.",
409
+ );
410
+ }
411
+
412
+ return withMeta(
413
+ { domain: args.domain, datasetId: args.datasetId, rows },
414
+ {
415
+ source: `${args.domain} ${SOURCE_SUFFIX}`,
416
+ keylessMode: true,
417
+ returned,
418
+ totalAvailable,
419
+ filtersApplied,
420
+ filtersDropped: [],
421
+ fieldsUnavailable: [],
422
+ pagination: { offset, limit, hasMore, nextOffset },
423
+ notes,
424
+ },
425
+ );
426
+ }
427
+
428
+ // ─── Tool 2: socrata_discover_datasets ────────────────────────────
429
+ export type CatalogDataset = {
430
+ id: string | null;
431
+ name: string | null;
432
+ description: string | null;
433
+ domain: string | null;
434
+ updatedAt: string | null;
435
+ link: string | null;
436
+ };
437
+
438
+ /** Map one catalog `results[]` entry → a stable discovery row. */
439
+ function mapCatalogRow(row: unknown): CatalogDataset {
440
+ const r = (row ?? {}) as {
441
+ resource?: Record<string, unknown>;
442
+ metadata?: { domain?: unknown };
443
+ permalink?: unknown;
444
+ link?: unknown;
445
+ };
446
+ const res = r.resource ?? {};
447
+ return {
448
+ id: str(res.id),
449
+ name: str(res.name),
450
+ description: str(res.description),
451
+ domain: str(r.metadata?.domain),
452
+ updatedAt: str(res.updatedAt),
453
+ link: str(r.link) ?? str(r.permalink),
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Discover dataset 4x4 ids via the Socrata catalog (memoized ~10 min). Omitting
459
+ * `domain` searches the WHOLE allowlist (repeated `domains=`); passing one scopes
460
+ * to it. Returns `[{ id, name, description, domain, updatedAt, link }]` +
461
+ * `totalAvailable = resultSetSize`. Feeds `datasetId` to socrata_query.
462
+ *
463
+ * m3 — this is the catalog's PRIMARY response: a non-number `resultSetSize` is a
464
+ * hard schema_drift throw (nothing valid to return), never a fabricated total.
465
+ * (Note: the catalog does not index every allowlisted host — e.g. USAC — so a
466
+ * host may return 0 here yet still be queryable via socrata_query with a known
467
+ * 4x4.)
468
+ */
469
+ export async function discoverDatasets(args: {
470
+ q: string;
471
+ domain?: SocrataDomain;
472
+ limit?: number;
473
+ }): Promise<MetaBundle> {
474
+ const limit = args.limit ?? 20;
475
+ const params = new URLSearchParams();
476
+ params.set("q", args.q);
477
+ params.set("only", "datasets");
478
+ params.set("limit", String(limit));
479
+ if (args.domain) {
480
+ params.append("domains", args.domain);
481
+ } else {
482
+ for (const d of SOCRATA_DOMAINS) params.append("domains", d);
483
+ }
484
+
485
+ const key = `socrata:catalog:${args.domain ?? "*"}:${args.q}:${limit}`;
486
+ const { totalAvailable, results } = await memoize(
487
+ key,
488
+ async () => {
489
+ const body = await getCatalog(params);
490
+ const b = (body ?? {}) as { results?: unknown; resultSetSize?: unknown };
491
+ // m3 — hard drift on the PRIMARY response (contrast the best-effort count).
492
+ // The check stays INSIDE the memoize callback so a bad shape is never
493
+ // cached as a success (ADR-0005 v2 test 2).
494
+ if (typeof b.resultSetSize !== "number") {
495
+ throw driftError(
496
+ "socrata:catalog",
497
+ "socrata:catalog returned an unexpected shape (resultSetSize must be a number).",
498
+ );
499
+ }
500
+ const total = num(b.resultSetSize);
501
+ const rows = Array.isArray(b.results) ? b.results.map(mapCatalogRow) : [];
502
+ return { totalAvailable: total, results: rows };
503
+ },
504
+ 10 * 60 * 1000,
505
+ );
506
+
507
+ const returned = results.length;
508
+ const scope = args.domain ? `domain ${args.domain}` : `the ${SOCRATA_DOMAINS.length}-host allowlist`;
509
+ const notes: string[] = [
510
+ `Catalog search over ${scope} (only=datasets). Feed a result's id to socrata_query as datasetId.`,
511
+ `App token: ${appTokenPresent() ? "present (X-App-Token sent; value never logged)" : "absent (keyless)"}.`,
512
+ ];
513
+ if (totalAvailable !== null && returned < totalAvailable) {
514
+ notes.push(
515
+ `Showing ${returned} of ${totalAvailable} matches; raise limit (≤100) or narrow q for the rest.`,
516
+ );
517
+ }
518
+
519
+ return withMeta(
520
+ { query: args.q, domain: args.domain ?? null, results },
521
+ {
522
+ source: `${CATALOG_HOST} catalog ${SOURCE_SUFFIX}`,
523
+ keylessMode: true,
524
+ returned,
525
+ totalAvailable,
526
+ filtersApplied: args.domain ? ["q", "domain"] : ["q"],
527
+ filtersDropped: [],
528
+ fieldsUnavailable: [],
529
+ notes,
530
+ } satisfies Partial<ResponseMeta>,
531
+ );
532
+ }