@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/nih.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * NIH RePORTER v2 — federal research-GRANT project records (keyless).
3
+ *
4
+ * A NEW capability axis: **federal research-funding footprint by organization /
5
+ * UEI / state** — awarded NIH project money keyed to a recipient org (with UEI +
6
+ * DUNS), a recipient-ENRICHMENT layer orthogonal to the spend/contract/regulatory
7
+ * sources. It joins the SAM/USAspending recipient graph via `primary_uei` (the
8
+ * SAME UEI space) — but the award nature DIFFERS (grants fund research, not
9
+ * goods/services), so every response carries the grant-vs-contract caveat (M2).
10
+ *
11
+ * ON-DOMAIN HONESTY: this is research-funding / grants-adjacent, the AWARDED-money
12
+ * sibling of grants.gov opportunities and the biomedical-R&D slice of USAspending
13
+ * assistance awards — NOT core procurement. Positioned as recipient-enrichment /
14
+ * R&D market intel, never as a contract source.
15
+ *
16
+ * THE R2 PORT'S FIRST NON-GET CONSUMER (ADR-0014): NIH RePORTER is a
17
+ * POST-with-JSON-body API. `getJson` (ADR-0005) gained a byte-neutral,
18
+ * backward-compatible `method`/`body` passthrough for exactly this; this module
19
+ * writes ZERO fetch/coercion/error code — it REUSES `getJson` / `driftError` /
20
+ * `num`·`str` / `withMeta` and COPIES (does not import) the fixed-host SSRF +
21
+ * honesty PATTERN from CKAN/ECHO/FPDS, adapted:
22
+ *
23
+ * ★ SSRF GUARD (policy① — the SMALLEST surface of any source): the request URL is
24
+ * a compile-time CONSTANT (fixed host `api.reporter.nih.gov` + fixed path
25
+ * `/v2/projects/search`); NO caller input touches it. All filters ride in the POST
26
+ * body, which is MODULE-BUILT from a validated typed criteria object then
27
+ * `JSON.stringify`'d — there is NO raw-body / raw-criteria passthrough, so a
28
+ * caller value cannot break out of its criterion into another key. A
29
+ * post-construction hostname/protocol assertion + `redirect:"error"` lock it
30
+ * (fail closed on any off-host 3xx; its body is never read).
31
+ *
32
+ * ★ FILTER HONESTY (M1 — the load-bearing discipline): a criterion is shipped ONLY
33
+ * after being LIVE-CONFIRMED to actually narrow the result set. The confirmed
34
+ * v1 set is `orgStates` + `orgNames` + `fiscalYears` (each live-verified to
35
+ * reduce the total below the unfiltered baseline). `agency_ic_codes` is EXCLUDED
36
+ * — it is silently DROPPED upstream (a filter that no-ops is never presented as
37
+ * applied). A shipped filter goes in `_meta.filtersApplied`; a silent-drop filter
38
+ * is never listed there and never exposed in the schema.
39
+ *
40
+ * ★ 15,000-RECORD RETRIEVAL WINDOW (M3+M4 — the critical disclosure): NIH caps
41
+ * keyless retrieval at `offset 0..14,999` (`limit ≤ 500`), so only the first
42
+ * 15,000 records of any result set are reachable. This is a cap on RETRIEVAL, NOT
43
+ * on the COUNT: `meta.total` stays EXACT (never truncated to 15,000, never marked
44
+ * `totalIsLowerBound`). `offset >= 15,000` is refused pre-fetch (`invalid_input`);
45
+ * after a page, `nextOffset` is null once `offset+returned` reaches the cap or the
46
+ * exact total (never a dead-end `nextOffset=15000`). `truncated` is derived by
47
+ * buildMeta from `returned < totalAvailable`; when the count exceeds the window a
48
+ * `_meta.notes` line discloses the unreachable remainder and recommends narrowing.
49
+ *
50
+ * ★ HONESTY `_meta`: shape-guard FIRST (a 200 body that is not `{meta,results:[]}`
51
+ * → driftError, NEVER a fake empty); a non-numeric/absent `meta.total` →
52
+ * driftError BEFORE `num(meta.total)`; genuine-empty (`total:0`) → complete:true /
53
+ * totalAvailable:0; outage/5xx/timeout → getJson throws; 400 (bad offset/limit/
54
+ * type) → invalid_input (surfaced, never `[]`). `num` is null-never-0 on
55
+ * `award_amount` (a real $0 award is 0; an absent amount is null). Every response
56
+ * discloses the grant-vs-contract caveat + a data-currency note.
57
+ */
58
+
59
+ import { ToolErrorCarrier } from "./errors.js";
60
+ import { getJson, driftError } from "./datasource.js";
61
+ import { num, str } from "./coerce.js";
62
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
63
+
64
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
65
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
66
+ // `num` (nih.num === coerce.num === treasury.num — a num regression fails together).
67
+ export { num };
68
+
69
+ // ─── Fixed endpoint (SSRF core — a compile-time CONSTANT) ─────────
70
+ const NIH_HOST = "api.reporter.nih.gov";
71
+ const NIH_PATH = "/v2/projects/search";
72
+ const NIH_PROJECTS_URL = `https://${NIH_HOST}${NIH_PATH}`;
73
+ // HOST-only-ish label (path is fixed + carries no token — keyless). Surfaces in
74
+ // ToolError.upstreamEndpoint; no secret can appear here (the API is anonymous).
75
+ const NIH_LABEL = "nih:/v2/projects/search";
76
+
77
+ // ─── The 15,000-record RETRIEVAL window (live-verified: offset ≤ 14,999,
78
+ // limit ≤ 500). A cap on RETRIEVAL, not on the exact meta.total count. The
79
+ // limit ≤ 500 ceiling is enforced by the server's Zod schema; this module owns
80
+ // the offset-window guard + the never-a-dead-end nextOffset boundary. ──
81
+ const RETRIEVAL_CAP = 15_000;
82
+
83
+ // ─── Frozen US state/territory 2-letter USPS enum (UPPERCASE-only) ─
84
+ // Built FROM this array by the Zod enum in server.ts (single source of truth).
85
+ // It is BOTH an org_states value guard AND the silent-zero guard: live, an unknown
86
+ // but well-typed value (e.g. "ZZ") returns a genuine total:0 indistinguishable
87
+ // from a real empty, and a lowercase "ca" silently returns zeros — so a typo must
88
+ // be an invalid_input, never read as "no NIH funding" (a silent honesty failure).
89
+ export const NIH_ORG_STATES = [
90
+ "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL",
91
+ "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME",
92
+ "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH",
93
+ "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI",
94
+ "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI",
95
+ "WY", "AS", "GU", "MP", "PR", "VI",
96
+ ] as const;
97
+
98
+ export type NihOrgState = (typeof NIH_ORG_STATES)[number];
99
+
100
+ // ─── Disclosure constants (honesty obligations) ──────────────────
101
+ /** M2 — the mandatory grant-vs-contract caveat carried in EVERY response. */
102
+ const NIH_GRANT_CAVEAT =
103
+ "NIH RePORTER records are RESEARCH GRANTS awarded by NIH, NOT federal procurement contracts. The primary_uei joins to SAM/USAspending recipient records, but the award nature differs (grants fund research, not goods/services) — do not present these amounts as contract awards.";
104
+
105
+ /** m-currency — a conservative data-currency note (not API-verifiable). */
106
+ const DATA_CURRENCY_NOTE =
107
+ "NIH RePORTER updates on a rolling basis; per-record refresh lag is not API-verifiable.";
108
+
109
+ /** The UEI join disclosure — how to bridge to the SAM/USAspending recipient graph. */
110
+ const UEI_JOIN_NOTE =
111
+ "organization.primaryUei is the join key to SAM entities and USAspending recipients (same UEI space); a grant recipient is not necessarily a federal contractor.";
112
+
113
+ /** M3+M4 — the retrieval-window disclosure when the count exceeds the 15,000 cap. */
114
+ function retrievalCapNote(total: number): string {
115
+ return `NIH caps keyless retrieval at the first ${RETRIEVAL_CAP} of ${total} matching records; the count is EXACT but records beyond ${RETRIEVAL_CAP} cannot be retrieved via this API — narrow criteria (org, fiscal year, state) to bring the target set under ${RETRIEVAL_CAP}.`;
116
+ }
117
+
118
+ const SOURCE = "api.reporter.nih.gov v2 (keyless)";
119
+
120
+ // ─── Record shape (recipient-enrichment payload) ─────────────────
121
+ export type NihPrincipalInvestigator = {
122
+ profileId: number | null;
123
+ firstName: string | null;
124
+ lastName: string | null;
125
+ fullName: string | null;
126
+ isContactPi: boolean | null;
127
+ title: string | null;
128
+ };
129
+
130
+ export type NihProject = {
131
+ projectNum: string | null;
132
+ projectTitle: string | null;
133
+ fiscalYear: number | null;
134
+ awardAmount: number | null;
135
+ awardType: string | null;
136
+ activityCode: string | null;
137
+ isActive: boolean | null;
138
+ organization: {
139
+ name: string | null;
140
+ city: string | null;
141
+ state: string | null;
142
+ country: string | null;
143
+ /** The SAM/USAspending recipient join key (but grant ≠ contract — M2). */
144
+ primaryUei: string | null;
145
+ primaryDuns: string | null;
146
+ ueis: string[];
147
+ duns: string[];
148
+ };
149
+ principalInvestigators: NihPrincipalInvestigator[];
150
+ contactPiName: string | null;
151
+ fundingIc: {
152
+ code: string | null;
153
+ abbreviation: string | null;
154
+ name: string | null;
155
+ };
156
+ };
157
+
158
+ /** "true"/true → true, "false"/false → false, absent/other → null (never a
159
+ * fabricated false). */
160
+ function boolOrNull(x: unknown): boolean | null {
161
+ if (x === true) return true;
162
+ if (x === false) return false;
163
+ if (x === "true") return true;
164
+ if (x === "false") return false;
165
+ return null;
166
+ }
167
+
168
+ /** A 2-letter uppercase string array from a mixed value, else []. */
169
+ function strArray(x: unknown): string[] {
170
+ if (!Array.isArray(x)) return [];
171
+ return x.map((v) => str(v)).filter((v): v is string => v !== null);
172
+ }
173
+
174
+ /** Map ONE NIH results[] row → the curated enrichment shape. Every scalar is
175
+ * null-never-fabricated (str/num); `award_amount` is num (a real $0 → 0, absent
176
+ * → null). Field names are NIH v2 documented keys; an absent field maps to null
177
+ * (honest "unknown"), never a crash. */
178
+ function mapProject(raw: unknown): NihProject {
179
+ const r = (raw ?? {}) as Record<string, unknown>;
180
+ const org = (r.organization ?? {}) as Record<string, unknown>;
181
+ const ic = (r.agency_ic_admin ?? {}) as Record<string, unknown>;
182
+ const pis = Array.isArray(r.principal_investigators)
183
+ ? (r.principal_investigators as Record<string, unknown>[])
184
+ : [];
185
+ return {
186
+ projectNum: str(r.project_num),
187
+ projectTitle: str(r.project_title),
188
+ fiscalYear: num(r.fiscal_year),
189
+ awardAmount: num(r.award_amount),
190
+ awardType: str(r.award_type),
191
+ activityCode: str(r.activity_code),
192
+ isActive: boolOrNull(r.is_active),
193
+ organization: {
194
+ name: str(org.org_name),
195
+ city: str(org.org_city),
196
+ state: str(org.org_state),
197
+ country: str(org.org_country),
198
+ primaryUei: str(org.primary_uei),
199
+ primaryDuns: str(org.primary_duns),
200
+ ueis: strArray(org.org_ueis),
201
+ duns: strArray(org.org_duns),
202
+ },
203
+ principalInvestigators: pis.map((p) => ({
204
+ profileId: num(p.profile_id),
205
+ firstName: str(p.first_name),
206
+ lastName: str(p.last_name),
207
+ fullName: str(p.full_name),
208
+ isContactPi: boolOrNull(p.is_contact_pi),
209
+ title: str(p.title),
210
+ })),
211
+ contactPiName: str(r.contact_pi_name),
212
+ fundingIc: {
213
+ code: str(ic.code),
214
+ abbreviation: str(ic.abbreviation),
215
+ name: str(ic.name),
216
+ },
217
+ };
218
+ }
219
+
220
+ // ─── The confirmed criteria surface (v1) ─────────────────────────
221
+ export type NihSearchArgs = {
222
+ orgStates?: NihOrgState[];
223
+ orgNames?: string[];
224
+ fiscalYears?: number[];
225
+ limit?: number;
226
+ offset?: number;
227
+ };
228
+
229
+ /**
230
+ * Search awarded NIH research projects (POST /v2/projects/search). Structured,
231
+ * LIVE-CONFIRMED-narrowing criteria only (orgStates / orgNames / fiscalYears),
232
+ * AND-combined in a MODULE-BUILT criteria object → JSON.stringify (no raw
233
+ * passthrough). Returns curated recipient-enrichment rows + honest `_meta`:
234
+ * exact totalAvailable, exact within-window pagination, the 15,000-retrieval-cap
235
+ * disclosure when the count exceeds it, and the mandatory grant-vs-contract
236
+ * caveat. Disclose-not-refuse: an unscoped query is NOT refused — it returns the
237
+ * first page + the exact total + a note recommending a criterion when the total
238
+ * exceeds the window.
239
+ */
240
+ export async function searchProjects(args: NihSearchArgs): Promise<MetaBundle> {
241
+ const limit = args.limit ?? 50;
242
+ const offset = args.offset ?? 0;
243
+
244
+ // ── 15k window pre-fetch guard (M3): offset >= 15,000 is UNREACHABLE — refuse
245
+ // BEFORE any fetch (also enforced by the server's Zod .max(14_999); this is
246
+ // the belt-and-suspenders module guard so a direct caller can't slip past). ──
247
+ if (offset >= RETRIEVAL_CAP) {
248
+ throw new ToolErrorCarrier({
249
+ kind: "invalid_input",
250
+ message: `NIH RePORTER caps pagination at the first ${RETRIEVAL_CAP} records (offset 0..${RETRIEVAL_CAP - 1}); offset ${offset} is unreachable via this keyless API. Narrow criteria (org, fiscal year, state) to bring the target set under ${RETRIEVAL_CAP}.`,
251
+ retryable: false,
252
+ upstreamEndpoint: NIH_LABEL,
253
+ });
254
+ }
255
+
256
+ // ── Build the criteria object from VALIDATED typed input (SSRF: no raw
257
+ // passthrough; every value is enum/typed by the server's Zod before here). A
258
+ // filter is added — and listed in filtersApplied — ONLY when it is one of the
259
+ // LIVE-CONFIRMED narrowing criteria (M1). agency_ic_codes is never built (it
260
+ // silently no-ops upstream and is not in the schema). ──
261
+ const criteria: Record<string, unknown> = {};
262
+ const filtersApplied: string[] = [];
263
+ if (args.fiscalYears && args.fiscalYears.length > 0) {
264
+ criteria.fiscal_years = args.fiscalYears;
265
+ filtersApplied.push("fiscalYears");
266
+ }
267
+ if (args.orgStates && args.orgStates.length > 0) {
268
+ criteria.org_states = args.orgStates;
269
+ filtersApplied.push("orgStates");
270
+ }
271
+ if (args.orgNames && args.orgNames.length > 0) {
272
+ criteria.org_names = args.orgNames;
273
+ filtersApplied.push("orgNames");
274
+ }
275
+
276
+ const payload = { criteria, limit, offset };
277
+
278
+ // ── SSRF belt-and-suspenders: the URL is a compile-time constant, but assert it
279
+ // cannot have drifted (a future typo) — exactly the CKAN/FPDS post-construction
280
+ // check, adapted (no URLSearchParams: there are no query params). ──
281
+ const built = new URL(NIH_PROJECTS_URL);
282
+ if (built.hostname !== NIH_HOST || built.protocol !== "https:") {
283
+ throw new ToolErrorCarrier({
284
+ kind: "invalid_input",
285
+ message: `Constructed NIH URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NIH_HOST} over https — refusing to fetch (SSRF safety).`,
286
+ retryable: false,
287
+ upstreamEndpoint: NIH_LABEL,
288
+ });
289
+ }
290
+
291
+ // ── The R2 port's first POST call. Content-Type is MANDATORY for NIH (415
292
+ // without it, 405 on GET). redirect:"error" fails closed on any off-host 3xx
293
+ // (its body is never read). The body is the module-built, JSON.stringify'd
294
+ // typed payload — never string-concatenated. ──
295
+ const body = await getJson<unknown>(NIH_PROJECTS_URL, {
296
+ label: NIH_LABEL,
297
+ method: "POST",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: JSON.stringify(payload),
300
+ redirect: "error",
301
+ });
302
+
303
+ // ── Shape guard FIRST → THROW (never fake-empty). A 200 body must be an OBJECT
304
+ // with a `meta` object and an array `results`. An array body (a 200-with-array
305
+ // drift), a missing `meta`, or a non-array `results` → driftError; NEVER read
306
+ // `results` as []. ──
307
+ if (
308
+ typeof body !== "object" ||
309
+ body === null ||
310
+ Array.isArray(body)
311
+ ) {
312
+ throw driftError(
313
+ NIH_LABEL,
314
+ "NIH /v2/projects/search returned a 200 body that is not an object {meta,results} (an array or scalar) — refusing to report it as an empty result.",
315
+ );
316
+ }
317
+ const b = body as { meta?: unknown; results?: unknown };
318
+ if (typeof b.meta !== "object" || b.meta === null || !Array.isArray(b.results)) {
319
+ throw driftError(
320
+ NIH_LABEL,
321
+ "NIH /v2/projects/search returned an unexpected shape (expected an object with a `meta` object and an array `results`).",
322
+ );
323
+ }
324
+ const meta = b.meta as { total?: unknown };
325
+
326
+ // ── m-total-guard: a non-number `meta.total` (or absent) is drift — num() cannot
327
+ // tell a non-number from an absent one, so typeof-check BEFORE num() (CKAN m6).
328
+ // Nothing trustworthy to report as a total → THROW, never proceed with null. ──
329
+ if (typeof meta.total !== "number") {
330
+ throw driftError(
331
+ NIH_LABEL,
332
+ "NIH meta.total absent/non-numeric — nothing trustworthy to report as a total (treating as schema drift).",
333
+ );
334
+ }
335
+ // EXACT total (§1a) — NEVER results.length, NEVER a lower bound. num() defensively
336
+ // (a finite number passes through; a non-finite would be caught, but the typeof
337
+ // guard above already rejected non-numbers).
338
+ const totalAvailable = num(meta.total);
339
+
340
+ const projects = (b.results as unknown[]).map(mapProject);
341
+ const returned = projects.length;
342
+
343
+ // ── Pagination (M3+M4): NEVER hand a dead-end nextOffset=15000. nextOffset is
344
+ // null once offset+returned reaches the cap OR the exact total; hasMore mirrors
345
+ // it. NO mid-page clamp (offset+limit>15000 does NOT 400 — only offset≥15000). ──
346
+ const candidateNext = offset + returned;
347
+ const nextOffset =
348
+ candidateNext >= RETRIEVAL_CAP ||
349
+ (totalAvailable !== null && candidateNext >= totalAvailable)
350
+ ? null
351
+ : candidateNext;
352
+ const hasMore = nextOffset !== null;
353
+
354
+ const notes: string[] = [NIH_GRANT_CAVEAT, UEI_JOIN_NOTE, DATA_CURRENCY_NOTE];
355
+ // Disclose-not-refuse + the reachability-cap disclosure: fire whenever the exact
356
+ // count exceeds the reachable window (records beyond 15,000 are UNREACHABLE — the
357
+ // honest superset of "truncated by the cap" and "an unscoped broad query").
358
+ if (totalAvailable !== null && totalAvailable > RETRIEVAL_CAP) {
359
+ notes.push(retrievalCapNote(totalAvailable));
360
+ }
361
+
362
+ const metaOut: Partial<ResponseMeta> = {
363
+ source: SOURCE,
364
+ keylessMode: true,
365
+ returned,
366
+ totalAvailable,
367
+ filtersApplied,
368
+ filtersDropped: [],
369
+ fieldsUnavailable: [],
370
+ pagination: { offset, limit, hasMore, nextOffset },
371
+ notes,
372
+ };
373
+
374
+ return withMeta({ projects }, metaOut);
375
+ }