@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/nsf.ts ADDED
@@ -0,0 +1,706 @@
1
+ /**
2
+ * NSF Awards API — federal research-GRANT award records (keyless).
3
+ *
4
+ * Source #20 on the R2 `getJson` port (ADR-0020). A recipient/PI/**UEI**-keyed
5
+ * research-funding footprint from `https://api.nsf.gov/services/v1/awards.json`,
6
+ * the grant-SIBLING of NIH RePORTER (ADR-0014) on a DIFFERENT agency. It
7
+ * strengthens the WEAK entity/recipient layer (C83) with a UEI-keyed recipient
8
+ * graph: `ueiNumber` / `parentUeiNumber` join to the SAM/USAspending recipient
9
+ * space (same UEI space) — but the award nature DIFFERS (grants fund research,
10
+ * not goods/services), so every response carries the grant-vs-contract caveat.
11
+ *
12
+ * ON-DOMAIN HONESTY: this is research-funding / grants-adjacent recipient
13
+ * enrichment — NOT core procurement. Positioned as a recipient-graph / R&D
14
+ * market-intel source, never as a contract source.
15
+ *
16
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
17
+ * (redirect:"error", no method/body — a plain GET) / `driftError` / `num`·`str`
18
+ * (coerce.ts, null-never-0) / `withMeta`·`buildMeta`, and COPIES (does NOT
19
+ * import) the fixed-host SSRF idiom from ECHO + the `boolOrNull` helper from NIH.
20
+ *
21
+ * ★ SSRF GUARD (policy① — the ECHO/CKAN fixed-host pattern): the request host +
22
+ * path are compile-time CONSTANTS; NO caller value touches them. Every filter
23
+ * rides in a MODULE-BUILT `URLSearchParams` (each value URLSearchParams-encoded)
24
+ * assembled key-by-key from the validated typed args — there is NO raw-query
25
+ * passthrough, so a caller value cannot break out of its param into the host /
26
+ * path / another param (LIVE-verified 2026-07-12: `keyword=robotics%26agency=
27
+ * NASA` keeps the `&` inside the value — no param split). A post-construction
28
+ * hostname/protocol assertion + `redirect:"error"` lock it (fail closed on any
29
+ * off-host 3xx; its body is never read). `id` is numeric-only.
30
+ *
31
+ * ★ THE THREE HONESTY FACTS (LIVE-verified 2026-07-12, keyless plain GET):
32
+ * 1. `metadata.totalCount` is EXACT below 10,000 and SATURATES at 10,000 (an
33
+ * Elasticsearch `track_total_hits` cap). So `< 10000` ⇒ EXACT; `=== 10000`
34
+ * ⇒ a disclosed LOWER BOUND (`totalIsLowerBound:true` — the true total is
35
+ * unknown and ≥ 10,000, and only the first 10,000 are retrievable); `0` ⇒ a
36
+ * genuine empty. (robotics=9038, ueiNumber=FTMTDMBR29C7=3396 all-JHU,
37
+ * cryptography=2598, dates 2024=369 — all EXACT; unfiltered / university /
38
+ * science / quantum / awardeeName=JHU all =10000.)
39
+ * 2. The retrieval window is `offset + rpp ≤ 10,000` (P13 confirmed:
40
+ * offset=9980&rpp=20 OK, offset=9981&rpp=20 → FATAL AwardAPI-004). The
41
+ * reachable slice and the count floor coincide at exactly 10,000.
42
+ * 3. Errors are BODY-level loud-fails at HTTP 200 (P4/P12): a bad param ⇒
43
+ * `serviceNotification` ERROR/FATAL with `metadata` DROPPED. `getJson` will
44
+ * NOT throw on these (200 parses) — so the handler inspects
45
+ * `serviceNotification` and THROWS, NEVER reads the empty `award:[]` as a
46
+ * genuine result. Genuine-empty is the DISTINCT `totalCount:0`-with-no-
47
+ * notification shape.
48
+ *
49
+ * ★ [M1] MULTI-TOKEN KEYWORD OR-TOKENIZATION (normative disclosure): NSF's ES
50
+ * analyzer OR-splits a `keyword` into tokens and matches ANY of them (a UNION),
51
+ * not the phrase — and it splits on whitespace AND PUNCTUATION, not whitespace
52
+ * alone (LIVE-verified: cryptography=2598 + volcano=1655 → "cryptography volcano"
53
+ * =4253; and the hyphen/comma/slash/semicolon/plus/&/|/@/#/= forms all return the
54
+ * SAME OR union — while `.`/`:`/`_`/`'`/`\`/`*` do NOT split, and `~`/`(`/`)`
55
+ * loud-fail). The honest-looking (often saturated) totalCount + rows carry NO
56
+ * signal the tokens were unioned, so when `keyword` splits into 2+ tokens on the
57
+ * shared confirmed-splitter class (tokenizeForDisclosure / DISCLOSURE_SPLIT_RE —
58
+ * whitespace + the confirmed punctuation set, NOT just whitespace) the module
59
+ * emits a MANDATORY `_meta.notes` line disclosing the OR-semantics. This closes
60
+ * the compound-token leak (e.g. "coral-reef" = coral OR reef, a far broader set
61
+ * than a caller intends).
62
+ *
63
+ * ★ AMOUNTS are STRINGS → `coerce.num` (null-never-0): a real $0 → 0; absent/""/
64
+ * "null" → null (NEVER 0). `fundsObligatedAmt` (obligated to date) and
65
+ * `estimatedTotalAmt` (estimated total life value) are labeled distinctly.
66
+ *
67
+ * ★ `piMiddeInitial` is copied VERBATIM — the source key is genuinely misspelled
68
+ * (missing the second `l`). Reading `piMiddleInitial` would silently drop real
69
+ * PI data.
70
+ */
71
+
72
+ import { ToolErrorCarrier } from "./errors.js";
73
+ import { getJson, driftError } from "./datasource.js";
74
+ import { num, str } from "./coerce.js";
75
+ import { tokenizeForDisclosure } from "./disclosure.js";
76
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
77
+
78
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
79
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
80
+ // `num` (nsf.num === coerce.num === nih.num — a num regression fails together).
81
+ export { num };
82
+
83
+ // Re-export the shared disclosure tokenizer (single audited copy in
84
+ // ./disclosure.js — ADR-0022) so the fault suite's parity guard resolves the SAME
85
+ // function (nsf.tokenizeForDisclosure === clinicaltrials.tokenizeForDisclosure ===
86
+ // disclosure.tokenizeForDisclosure — a class regression fails both suites at once).
87
+ export { tokenizeForDisclosure } from "./disclosure.js";
88
+
89
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
90
+ const NSF_HOST = "api.nsf.gov";
91
+ const NSF_PATH = "/services/v1/awards.json";
92
+ const NSF_URL = `https://${NSF_HOST}${NSF_PATH}`;
93
+ // HOST-only-ish label (path is fixed + carries no token — keyless). Surfaces in
94
+ // ToolError.upstreamEndpoint; no secret can appear here (the API is anonymous).
95
+ const NSF_LABEL = "nsf:/services/v1/awards.json";
96
+
97
+ // ─── The 10,000-record retrieval window + count-saturation cap ─────
98
+ // LIVE-verified: metadata.totalCount saturates at 10,000 (ES track_total_hits)
99
+ // AND offset+rpp>10,000 → FATAL AwardAPI-004. The reachable slice and the count
100
+ // floor coincide at exactly 10,000.
101
+ const RETRIEVAL_WINDOW = 10_000;
102
+
103
+ // ─── Client-side value grammars (SSRF + silent-foot-gun guards) ────
104
+ // A UEI is 12 uppercase alnum; the API is case-insensitive, so we uppercase-
105
+ // normalize before sending for a stable exact match.
106
+ const UEI_RE = /^[A-Za-z0-9]{12}$/;
107
+ // STRICT mm/dd/yyyy: a wrong format (e.g. 2024-01-01) is silently MIS-PARSED by
108
+ // NSF (845 ≠ 369), NOT loud-failed — so it must be rejected client-side.
109
+ const MMDDYYYY_RE = /^(0[1-9]|1[0-2])\/(0[1-9]|[12]\d|3[01])\/\d{4}$/;
110
+ // NSF award ids are all-digit. LIVE-verified 2026-07-12 across 1982 ids spanning
111
+ // 1950..2026: EVERY id is 7 digits (min 2423690, max 8207220, 0 non-numeric).
112
+ // `\d{5,9}` comfortably contains that with headroom on both ends (do not narrow
113
+ // to exactly 7 and risk rejecting an unsampled legitimate id); numeric-only is
114
+ // the actual injection-safety property.
115
+ const AWARD_ID_RE = /^\d{5,9}$/;
116
+ // M1 — a multi-token keyword is disclosed via the SHARED tokenizeForDisclosure
117
+ // (src/disclosure.js, ADR-0022): NSF's Elasticsearch analyzer OR-tokenizes a
118
+ // keyword on whitespace AND the confirmed PUNCTUATION set (space + `- , / ; + & |
119
+ // @ # =` ALL split; `. : _ ' \ *` do NOT; `~ ( )` loud-fail upstream), so a
120
+ // single-token-LOOKING compound like "coral-reef" is really coral OR reef — the
121
+ // shared DISCLOSURE_SPLIT_RE class detects multi-token on THAT precise set (not a
122
+ // whitespace-only split, and not a non-alnum superset that would over-disclose a
123
+ // union NSF did not make on `.`/`_`/`'`) so the mandatory OR-note is never skipped.
124
+
125
+ // ─── Frozen US state/territory 2-letter USPS enum (UPPERCASE-only) ─
126
+ // Built FROM this array by the Zod enum in server.ts (single source of truth).
127
+ // It is BOTH the awardeeStateCode value guard AND the silent-zero guard: LIVE,
128
+ // an unknown-but-well-typed value ("ZZ") returns a genuine totalCount:0
129
+ // indistinguishable from "no NSF funding in that state", and a lowercase "ca"
130
+ // silently matches (case-insensitive) — so a NON-state typo must be an
131
+ // invalid_input, never read as "no NSF funding" (a silent honesty failure).
132
+ export const NSF_STATES = [
133
+ "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL",
134
+ "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME",
135
+ "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH",
136
+ "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI",
137
+ "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI",
138
+ "WY", "AS", "GU", "MP", "PR", "VI",
139
+ ] as const;
140
+
141
+ export type NsfState = (typeof NSF_STATES)[number];
142
+ const NSF_STATES_SET: ReadonlySet<string> = new Set(NSF_STATES);
143
+
144
+ // ─── Disclosure constants (honesty obligations) ──────────────────
145
+ /** M2 — the mandatory grant-vs-contract caveat carried in EVERY response. */
146
+ const NSF_GRANT_CAVEAT =
147
+ "NSF Awards are RESEARCH GRANTS made by the National Science Foundation, NOT federal procurement contracts. awardee.ueiNumber / parentUeiNumber join to SAM entities and USAspending recipients (same UEI space), but the award nature differs (grants fund research, not goods/services) — do not present these amounts as contract awards.";
148
+
149
+ /** The UEI join disclosure — how to bridge to the SAM/USAspending recipient graph. */
150
+ const UEI_JOIN_NOTE =
151
+ "awardee.ueiNumber is the EXACT join key to SAM entities and USAspending recipients (same UEI space); parentUeiNumber rolls up to the parent organization. A grant recipient is not necessarily a federal contractor.";
152
+
153
+ /** Amount labeling disclosure (the two amounts mean different things). */
154
+ const AMOUNTS_NOTE =
155
+ "amounts.fundsObligatedAmt = funds obligated to date; amounts.estimatedTotalAmt = estimated total award value over its life (labeled distinctly). Both arrive as strings and are coerced null-never-0 (a real $0 stays 0; an absent amount is null, never a fabricated 0). amounts.fundsObligatedByYear is a verbatim display-only array.";
156
+
157
+ /** A conservative data-currency note (not API-verifiable). */
158
+ const DATA_CURRENCY_NOTE =
159
+ "NSF updates awards on a rolling basis; per-record refresh lag is not API-verifiable.";
160
+
161
+ /** Live-verified date-filter semantics (Open-Q6 — do not assume). */
162
+ const DATE_SEMANTICS_NOTE =
163
+ "dateStart / dateEnd filter on the award ACTION date (the initial award / obligation date — the `date` / initAmendmentDate field, live-verified 2026-07-12), NOT the project startDate or expDate. Format is STRICT mm/dd/yyyy; a yyyy-mm-dd (or any other format) is silently mis-parsed by NSF (not an error), so it is rejected client-side.";
164
+
165
+ /** #1 — the 10,000 saturation lower-bound disclosure. */
166
+ const LOWER_BOUND_NOTE =
167
+ "NSF returns an exact match count only below 10,000; this query has AT LEAST 10,000 matching awards (the exact total is not exposed) and only the first 10,000 are retrievable — narrow filters (state, UEI, PI, date, keyword) to bring the set under 10,000 for an exact count and full reachability.";
168
+
169
+ /** #7 — the effective-rpp clamp disclosure (when the page hit the window edge). */
170
+ function clampNote(rpp: number): string {
171
+ return `The requested page would cross NSF's ${RETRIEVAL_WINDOW}-record retrieval window; the outgoing page size was reduced to ${rpp} to keep offset+rpp ≤ ${RETRIEVAL_WINDOW} (crossing it triggers a FATAL AwardAPI-004). Records beyond ${RETRIEVAL_WINDOW} are unreachable via this keyless API.`;
172
+ }
173
+
174
+ /** M1 — the multi-token keyword OR-tokenization disclosure. */
175
+ function orSemanticsNote(tokens: string[]): string {
176
+ return `NSF tokenizes a keyword on whitespace AND punctuation (hyphen, comma, slash, semicolon, etc.) and matches it as a UNION of its tokens — this result matches awards containing ANY of [${tokens.join(", ")}], NOT the exact phrase/compound; the total may be far broader than intended. For a narrower set, use a single distinctive keyword or add a scoping filter (state, UEI, PI, date).`;
177
+ }
178
+
179
+ const SOURCE = "api.nsf.gov /services/v1/awards.json (keyless)";
180
+
181
+ // ─── Curated record shape (recipient-enrichment payload) ──────────
182
+ export type NsfAward = {
183
+ id: string | null;
184
+ title: string | null;
185
+ agency: string | null;
186
+ awardAgencyCode: string | null;
187
+ fundAgencyCode: string | null;
188
+ cfdaNumber: string | null;
189
+ transType: string | null;
190
+ awardee: {
191
+ /** The SAM/USAspending recipient join key (but grant ≠ contract — M2). */
192
+ name: string | null;
193
+ legalName: string | null;
194
+ city: string | null;
195
+ stateCode: string | null;
196
+ countryCode: string | null;
197
+ zipCode: string | null;
198
+ districtCode: string | null;
199
+ ueiNumber: string | null;
200
+ parentUeiNumber: string | null;
201
+ };
202
+ performanceSite: {
203
+ location: string | null;
204
+ city: string | null;
205
+ stateCode: string | null;
206
+ countryCode: string | null;
207
+ zipCode: string | null;
208
+ };
209
+ principalInvestigator: {
210
+ fullName: string | null;
211
+ firstName: string | null;
212
+ lastName: string | null;
213
+ /** raw.piMiddeInitial — the source key is genuinely MISSPELLED (verbatim). */
214
+ middleInitial: string | null;
215
+ email: string | null;
216
+ id: string | null;
217
+ };
218
+ coPrincipalInvestigators: string[];
219
+ programOfficer: { name: string | null; email: string | null };
220
+ amounts: {
221
+ fundsObligatedAmt: number | null;
222
+ estimatedTotalAmt: number | null;
223
+ fundsObligatedByYear: string[];
224
+ };
225
+ dates: {
226
+ startDate: string | null;
227
+ expDate: string | null;
228
+ lastActionDate: string | null;
229
+ initAmendmentDate: string | null;
230
+ latestAmendmentDate: string | null;
231
+ };
232
+ program: {
233
+ fundProgramName: string | null;
234
+ program: string | null;
235
+ directorateAbbr: string | null;
236
+ divisionAbbr: string | null;
237
+ orgLongName: string | null;
238
+ orgLongName2: string | null;
239
+ };
240
+ activeAward: boolean | null;
241
+ historicalAward: boolean | null;
242
+ /** ONLY populated by nsf_get_award (search rows OMIT it — payload). */
243
+ abstractText?: string | null;
244
+ };
245
+
246
+ /** "true"/true → true, "false"/false → false, absent/other → null (never a
247
+ * fabricated false) — copied verbatim from nih.ts. */
248
+ function boolOrNull(x: unknown): boolean | null {
249
+ if (x === true) return true;
250
+ if (x === false) return false;
251
+ if (x === "true") return true;
252
+ if (x === "false") return false;
253
+ return null;
254
+ }
255
+
256
+ /** A string array from a mixed value, else [] (drops non-string entries). */
257
+ function strArray(x: unknown): string[] {
258
+ if (!Array.isArray(x)) return [];
259
+ return x.map((v) => str(v)).filter((v): v is string => v !== null);
260
+ }
261
+
262
+ /**
263
+ * Map ONE NSF award[] row → the curated enrichment shape. Every scalar is
264
+ * null-never-fabricated (str/num); the two amounts are `num` (a real $0 → 0,
265
+ * absent → null). `middleInitial` reads the VERBATIM misspelled `piMiddeInitial`.
266
+ * An absent field maps to null (honest "unknown"), never a crash. abstractText
267
+ * is included ONLY when `includeAbstract` (nsf_get_award) — search rows omit it.
268
+ */
269
+ function mapAward(raw: unknown, includeAbstract: boolean): NsfAward {
270
+ const r = (raw ?? {}) as Record<string, unknown>;
271
+ const award: NsfAward = {
272
+ id: str(r.id),
273
+ title: str(r.title),
274
+ agency: str(r.agency),
275
+ awardAgencyCode: str(r.awardAgencyCode),
276
+ fundAgencyCode: str(r.fundAgencyCode),
277
+ cfdaNumber: str(r.cfdaNumber),
278
+ transType: str(r.transType),
279
+ awardee: {
280
+ name: str(r.awardeeName),
281
+ legalName: str(r.awardee),
282
+ city: str(r.awardeeCity),
283
+ stateCode: str(r.awardeeStateCode),
284
+ countryCode: str(r.awardeeCountryCode),
285
+ zipCode: str(r.awardeeZipCode),
286
+ districtCode: str(r.awardeeDistrictCode),
287
+ ueiNumber: str(r.ueiNumber),
288
+ parentUeiNumber: str(r.parentUeiNumber),
289
+ },
290
+ performanceSite: {
291
+ location: str(r.perfLocation),
292
+ city: str(r.perfCity),
293
+ stateCode: str(r.perfStateCode),
294
+ countryCode: str(r.perfCountryCode),
295
+ zipCode: str(r.perfZipCode),
296
+ },
297
+ principalInvestigator: {
298
+ fullName: str(r.pdPIName),
299
+ firstName: str(r.piFirstName),
300
+ lastName: str(r.piLastName),
301
+ middleInitial: str(r.piMiddeInitial), // VERBATIM misspelled source key
302
+ email: str(r.piEmail),
303
+ id: str(r.piId),
304
+ },
305
+ coPrincipalInvestigators: strArray(r.coPDPI),
306
+ programOfficer: { name: str(r.poName), email: str(r.poEmail) },
307
+ amounts: {
308
+ fundsObligatedAmt: num(r.fundsObligatedAmt),
309
+ estimatedTotalAmt: num(r.estimatedTotalAmt),
310
+ fundsObligatedByYear: strArray(r.fundsObligated),
311
+ },
312
+ dates: {
313
+ startDate: str(r.startDate),
314
+ expDate: str(r.expDate),
315
+ lastActionDate: str(r.date),
316
+ initAmendmentDate: str(r.initAmendmentDate),
317
+ latestAmendmentDate: str(r.latestAmendmentDate),
318
+ },
319
+ program: {
320
+ fundProgramName: str(r.fundProgramName),
321
+ program: str(r.program),
322
+ directorateAbbr: str(r.dirAbbr),
323
+ divisionAbbr: str(r.divAbbr),
324
+ orgLongName: str(r.orgLongName),
325
+ orgLongName2: str(r.orgLongName2),
326
+ },
327
+ activeAward: boolOrNull(r.activeAwd),
328
+ historicalAward: boolOrNull(r.histAwd),
329
+ };
330
+ if (includeAbstract) award.abstractText = str(r.abstractText);
331
+ return award;
332
+ }
333
+
334
+ // ─── SSRF-guarded fetch (module-built URLSearchParams + hostname assert) ──
335
+ /**
336
+ * GET `api.nsf.gov/services/v1/awards.json` with the module-built query. SSRF:
337
+ * the host+path are constants; `params` are URLSearchParams-encoded values (no
338
+ * host-alteration surface); then the CONSTRUCTED URL's hostname === NSF_HOST
339
+ * (https) assertion. Sets `redirect:"error"` (off-host 3xx fails closed — its
340
+ * body is never read). NO headers (keyless — no key/UA required, byte-clean init).
341
+ * Returns the parsed JSON (unknown; the caller validates the response envelope).
342
+ */
343
+ async function nsfGet(params: URLSearchParams): Promise<unknown> {
344
+ const url = `${NSF_URL}?${params.toString()}`;
345
+ // Belt-and-suspenders: the FIXED host+path leave nothing to steer the
346
+ // authority; assert the built URL cannot have been moved off-host (a future
347
+ // constant typo / downgrade).
348
+ const built = new URL(url);
349
+ if (built.hostname !== NSF_HOST || built.protocol !== "https:") {
350
+ throw new ToolErrorCarrier({
351
+ kind: "invalid_input",
352
+ message: `Constructed NSF URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NSF_HOST} over https — refusing to fetch (SSRF safety).`,
353
+ retryable: false,
354
+ upstreamEndpoint: NSF_LABEL,
355
+ });
356
+ }
357
+ return getJson(built.toString(), { label: NSF_LABEL, redirect: "error" });
358
+ }
359
+
360
+ // ─── Response envelope parse (loud-fail + shape guards + exact total) ──
361
+ type ParsedNsf = {
362
+ totalAvailable: number;
363
+ totalIsLowerBound: boolean;
364
+ rawAwards: unknown[];
365
+ };
366
+
367
+ /**
368
+ * Validate the HTTP-200 body envelope and extract the EXACT total + raw rows.
369
+ * Guard ORDER (a body-level 200 can carry an error):
370
+ * (a) `serviceNotification` present AND `metadata` absent → a BODY-LEVEL LOUD-
371
+ * FAIL — THROW (never read award:[] as empty). AwardAPI-004 / FATAL (deep
372
+ * offset — we guard it pre-fetch) ⇒ upstream_unavailable (retryable); any
373
+ * other (AwardAPI-002 invalid param, or an unforeseen future type that
374
+ * drops metadata) ⇒ invalid_input surfacing the code+message.
375
+ * (b) `metadata` absent with NO notification, or a non-object `response`, or a
376
+ * non-array `award`, or a non-finite `metadata.totalCount` ⇒ driftError
377
+ * (schema_drift) — never a fabricated empty.
378
+ * (c) `totalCount === 10000` ⇒ totalIsLowerBound:true (ES saturation);
379
+ * `< 10000` ⇒ EXACT; `0` ⇒ a genuine empty (handled by the caller).
380
+ */
381
+ function parseNsfBody(body: unknown): ParsedNsf {
382
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
383
+ throw driftError(
384
+ NSF_LABEL,
385
+ "NSF awards.json returned a 200 body that is not an object {response:{…}} (an array or scalar) — refusing to report it as an empty result.",
386
+ );
387
+ }
388
+ const resp = (body as { response?: unknown }).response;
389
+ if (resp === null || typeof resp !== "object" || Array.isArray(resp)) {
390
+ throw driftError(
391
+ NSF_LABEL,
392
+ "NSF awards.json 200 body is missing the `response` object — treating as schema drift (never a fake empty).",
393
+ );
394
+ }
395
+ const r = resp as {
396
+ award?: unknown;
397
+ metadata?: unknown;
398
+ serviceNotification?: unknown;
399
+ };
400
+ const notifications = Array.isArray(r.serviceNotification)
401
+ ? (r.serviceNotification as Record<string, unknown>[])
402
+ : [];
403
+ const metadataPresent =
404
+ typeof r.metadata === "object" &&
405
+ r.metadata !== null &&
406
+ !Array.isArray(r.metadata);
407
+
408
+ // (a) BODY-LEVEL LOUD-FAIL: a serviceNotification with metadata DROPPED. NEVER
409
+ // read award:[] as an empty result (fault (c)). Every observed loud-fail
410
+ // (ERROR AwardAPI-002 / FATAL AwardAPI-004) drops metadata (v2-confirmed),
411
+ // so this catches them all — plus any unforeseen future type that drops it.
412
+ if (notifications.length > 0 && !metadataPresent) {
413
+ const first = notifications[0] ?? {};
414
+ const code = str(first.notificationCode) ?? "(no code)";
415
+ const type = str(first.notificationType) ?? "(no type)";
416
+ const message = str(first.notificationMessage) ?? "(no message)";
417
+ if (code === "AwardAPI-004" || type === "FATAL") {
418
+ // Deep-offset window overflow — we guard offset+rpp ≤ 10,000 PRE-fetch, so
419
+ // this is an unexpected upstream fault here (retryable).
420
+ throw new ToolErrorCarrier({
421
+ kind: "upstream_unavailable",
422
+ message: `NSF returned a FATAL service notification [${code}]: ${message} (an unexpected retrieval-window fault; the module guards offset+rpp ≤ ${RETRIEVAL_WINDOW} pre-fetch).`,
423
+ retryable: true,
424
+ retryAfterSeconds: 30,
425
+ upstreamEndpoint: NSF_LABEL,
426
+ });
427
+ }
428
+ throw new ToolErrorCarrier({
429
+ kind: "invalid_input",
430
+ message: `NSF rejected the request [${type} ${code}]: ${message}. (The module builds the query from a validated whitelist, so this indicates upstream parameter drift — never a silent empty.)`,
431
+ retryable: false,
432
+ upstreamEndpoint: NSF_LABEL,
433
+ });
434
+ }
435
+
436
+ // (b) SHAPE guard: metadata must be present + carry a finite totalCount.
437
+ if (!metadataPresent) {
438
+ throw driftError(
439
+ NSF_LABEL,
440
+ "NSF awards.json is missing `response.metadata` with no serviceNotification — nothing trustworthy to report as a total (treating as schema drift).",
441
+ );
442
+ }
443
+ const md = r.metadata as { totalCount?: unknown };
444
+ if (typeof md.totalCount !== "number" || !Number.isFinite(md.totalCount)) {
445
+ throw driftError(
446
+ NSF_LABEL,
447
+ "NSF metadata.totalCount absent/non-numeric — cannot report a trustworthy total (schema drift). typeof-checked BEFORE num() so a non-number can't silently parse.",
448
+ );
449
+ }
450
+ if (!Array.isArray(r.award)) {
451
+ throw driftError(
452
+ NSF_LABEL,
453
+ "NSF awards.json returned a non-array `response.award` with valid metadata — treating as schema drift (never a fake empty).",
454
+ );
455
+ }
456
+ // EXACT total (num defensively — the typeof+finite guard already passed, so
457
+ // this is guaranteed non-null; num keeps nsf.num === coerce.num single-source).
458
+ const totalAvailable = num(md.totalCount) as number;
459
+ // === 10,000 ⇒ SATURATED (ES track_total_hits) ⇒ a disclosed LOWER BOUND.
460
+ const totalIsLowerBound = totalAvailable === RETRIEVAL_WINDOW;
461
+ return { totalAvailable, totalIsLowerBound, rawAwards: r.award as unknown[] };
462
+ }
463
+
464
+ // ─── Tool 1: nsf_search_awards ────────────────────────────────────
465
+ export type NsfSearchArgs = {
466
+ keyword?: string;
467
+ awardeeStateCode?: NsfState;
468
+ awardeeName?: string;
469
+ ueiNumber?: string;
470
+ parentUeiNumber?: string;
471
+ pdPIName?: string;
472
+ dateStart?: string;
473
+ dateEnd?: string;
474
+ limit?: number;
475
+ offset?: number;
476
+ };
477
+
478
+ /**
479
+ * Search awarded NSF research grants with recipient / PI / UEI filters. Each
480
+ * shipped filter is LIVE-CONFIRMED to narrow (M1 discipline); the query is
481
+ * MODULE-BUILT from validated typed args through URLSearchParams (NO raw
482
+ * passthrough). Returns curated rows (abstract EXCLUDED — payload) + honest
483
+ * `_meta`: exact totalAvailable below 10k / a disclosed lower bound at 10k, the
484
+ * offset+rpp ≤ 10,000 window clamp, the multi-word OR disclosure, and the
485
+ * mandatory grant-vs-contract caveat. Disclose-not-refuse: an unscoped call is
486
+ * NOT refused — it returns the first page + the (lower-bound) total + a
487
+ * narrowing recommendation.
488
+ */
489
+ export async function searchAwards(args: NsfSearchArgs): Promise<MetaBundle> {
490
+ const limit = args.limit ?? 25;
491
+ const offset = args.offset ?? 0;
492
+
493
+ // ── Window pre-fetch guard (#7): offset ≥ 10,000 is UNREACHABLE — refuse
494
+ // BEFORE any fetch (also enforced by the server's Zod .max(9999); this is
495
+ // the belt-and-suspenders module guard for a direct caller). ──
496
+ if (offset >= RETRIEVAL_WINDOW) {
497
+ throw new ToolErrorCarrier({
498
+ kind: "invalid_input",
499
+ message: `NSF caps keyless retrieval at the first ${RETRIEVAL_WINDOW} records (offset 0..${RETRIEVAL_WINDOW - 1}); offset ${offset} is unreachable — narrow criteria (state, UEI, PI, date, keyword) to bring the target set under ${RETRIEVAL_WINDOW}.`,
500
+ retryable: false,
501
+ upstreamEndpoint: NSF_LABEL,
502
+ });
503
+ }
504
+
505
+ // ── Belt-and-suspenders value grammars (behind the server's Zod enum/regex).
506
+ // A direct caller must not slip a bad state (silent 0), a bad-format date
507
+ // (silent mis-parse), or a malformed UEI past these. ──
508
+ if (
509
+ args.awardeeStateCode !== undefined &&
510
+ !NSF_STATES_SET.has(args.awardeeStateCode)
511
+ ) {
512
+ throw new ToolErrorCarrier({
513
+ kind: "invalid_input",
514
+ message: `Invalid awardeeStateCode ${JSON.stringify(args.awardeeStateCode)} — expected a USPS 2-letter state/territory code (a non-state value silently returns 0 awards on NSF, indistinguishable from "no funding").`,
515
+ retryable: false,
516
+ upstreamEndpoint: NSF_LABEL,
517
+ });
518
+ }
519
+ for (const [k, v] of [
520
+ ["ueiNumber", args.ueiNumber],
521
+ ["parentUeiNumber", args.parentUeiNumber],
522
+ ] as const) {
523
+ if (v !== undefined && !UEI_RE.test(v)) {
524
+ throw new ToolErrorCarrier({
525
+ kind: "invalid_input",
526
+ message: `Invalid ${k} ${JSON.stringify(v)} — expected 12 alphanumeric characters (a SAM/USAspending UEI).`,
527
+ retryable: false,
528
+ upstreamEndpoint: NSF_LABEL,
529
+ });
530
+ }
531
+ }
532
+ for (const [k, v] of [
533
+ ["dateStart", args.dateStart],
534
+ ["dateEnd", args.dateEnd],
535
+ ] as const) {
536
+ if (v !== undefined && !MMDDYYYY_RE.test(v)) {
537
+ throw new ToolErrorCarrier({
538
+ kind: "invalid_input",
539
+ message: `Invalid ${k} ${JSON.stringify(v)} — expected STRICT mm/dd/yyyy (a wrong format like yyyy-mm-dd is silently mis-parsed by NSF, not an error).`,
540
+ retryable: false,
541
+ upstreamEndpoint: NSF_LABEL,
542
+ });
543
+ }
544
+ }
545
+
546
+ // ── Effective rpp clamp (#7 — LOAD-BEARING): outgoing rpp = min(limit,
547
+ // RETRIEVAL_WINDOW − offset) so a last page can NEVER cross 10,000 and
548
+ // trigger FATAL AwardAPI-004 (Zod alone permits offset 9999 + limit 100 =
549
+ // 10099 → FATAL). offset ≤ 9999 (guarded above) ⇒ rpp ≥ 1. ──
550
+ const rpp = Math.min(limit, RETRIEVAL_WINDOW - offset);
551
+
552
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
553
+ // passthrough). A filter is added — and listed in filtersApplied — ONLY when
554
+ // supplied AND live-confirmed to narrow (M1). agency / printFields are never
555
+ // sent (agency = NSF-only-corpus zeros-out foot-gun; printFields = proven
556
+ // no-op). UEIs are uppercase-normalized for a stable exact match. ──
557
+ const params = new URLSearchParams();
558
+ const filtersApplied: string[] = [];
559
+ let multiWordKeyword: string[] | null = null;
560
+ if (args.keyword !== undefined) {
561
+ params.set("keyword", args.keyword);
562
+ filtersApplied.push("keyword");
563
+ // M1: tokenize on NSF's REAL tokenizer delimiters via the shared
564
+ // tokenizeForDisclosure (whitespace AND the confirmed punctuation splitters —
565
+ // DISCLOSURE_SPLIT_RE), so a compound like "coral-reef" (= coral OR reef) fires
566
+ // the OR-note instead of leaking as one token through a non-whitespace delimiter.
567
+ const tokens = tokenizeForDisclosure(args.keyword);
568
+ if (tokens.length > 1) multiWordKeyword = tokens; // M1 OR-disclosure trigger
569
+ }
570
+ if (args.awardeeStateCode !== undefined) {
571
+ params.set("awardeeStateCode", args.awardeeStateCode.toUpperCase());
572
+ filtersApplied.push("awardeeStateCode");
573
+ }
574
+ if (args.awardeeName !== undefined) {
575
+ params.set("awardeeName", args.awardeeName);
576
+ filtersApplied.push("awardeeName");
577
+ }
578
+ if (args.ueiNumber !== undefined) {
579
+ params.set("ueiNumber", args.ueiNumber.toUpperCase());
580
+ filtersApplied.push("ueiNumber");
581
+ }
582
+ if (args.parentUeiNumber !== undefined) {
583
+ params.set("parentUeiNumber", args.parentUeiNumber.toUpperCase());
584
+ filtersApplied.push("parentUeiNumber");
585
+ }
586
+ if (args.pdPIName !== undefined) {
587
+ params.set("pdPIName", args.pdPIName);
588
+ filtersApplied.push("pdPIName");
589
+ }
590
+ if (args.dateStart !== undefined) {
591
+ params.set("dateStart", args.dateStart);
592
+ filtersApplied.push("dateStart");
593
+ }
594
+ if (args.dateEnd !== undefined) {
595
+ params.set("dateEnd", args.dateEnd);
596
+ filtersApplied.push("dateEnd");
597
+ }
598
+ params.set("offset", String(offset));
599
+ params.set("rpp", String(rpp));
600
+
601
+ const parsed = parseNsfBody(await nsfGet(params));
602
+ const { totalAvailable, totalIsLowerBound } = parsed;
603
+ const awards = parsed.rawAwards.map((raw) => mapAward(raw, false));
604
+ const returned = awards.length;
605
+
606
+ // ── Pagination within the window (#7): NEVER hand a dead-end nextOffset that
607
+ // would FATAL. nextOffset is null once candidateNext reaches the window OR
608
+ // (when the total is EXACT — not a lower bound) the exact total. When
609
+ // totalIsLowerBound (≥10k) the window edge (10,000) IS the reachable end. ──
610
+ const candidateNext = offset + returned;
611
+ const nextOffset =
612
+ candidateNext >= RETRIEVAL_WINDOW ||
613
+ (!totalIsLowerBound && candidateNext >= totalAvailable)
614
+ ? null
615
+ : candidateNext;
616
+ const hasMore = nextOffset !== null;
617
+
618
+ const notes: string[] = [NSF_GRANT_CAVEAT, UEI_JOIN_NOTE, AMOUNTS_NOTE];
619
+ if (multiWordKeyword) notes.push(orSemanticsNote(multiWordKeyword)); // M1
620
+ if (totalIsLowerBound) notes.push(LOWER_BOUND_NOTE);
621
+ if (rpp < limit) notes.push(clampNote(rpp));
622
+ if (args.dateStart !== undefined || args.dateEnd !== undefined)
623
+ notes.push(DATE_SEMANTICS_NOTE);
624
+ if (filtersApplied.length === 0)
625
+ notes.push(
626
+ "No filters were applied — this is an unscoped query over the whole NSF award corpus (the count saturates at 10,000). Add a filter (keyword, state, UEI, PI, date) for an exact count and a meaningful result set.",
627
+ );
628
+ notes.push(DATA_CURRENCY_NOTE);
629
+
630
+ const metaOut: Partial<ResponseMeta> = {
631
+ source: SOURCE,
632
+ keylessMode: true,
633
+ returned,
634
+ totalAvailable,
635
+ filtersApplied,
636
+ filtersDropped: [],
637
+ fieldsUnavailable: ["abstractText (search rows omit it — use nsf_get_award)"],
638
+ pagination: { offset, limit, hasMore, nextOffset },
639
+ notes,
640
+ };
641
+ if (totalIsLowerBound) metaOut.totalIsLowerBound = true;
642
+
643
+ return withMeta({ awards }, metaOut);
644
+ }
645
+
646
+ // ─── Tool 2: nsf_get_award ────────────────────────────────────────
647
+ /**
648
+ * Fetch ONE NSF award by its numeric award id; returns the FULL single record
649
+ * INCLUDING abstractText. Not-found is honest: `id=<nonexistent>` returns
650
+ * totalCount:0 / award:[] (a genuine empty) ⇒ found:false, NEVER a fabricated
651
+ * record. `id` is numeric-only (injection-safe).
652
+ */
653
+ export async function getAward(args: { awardId: string }): Promise<MetaBundle> {
654
+ // Belt-and-suspenders (behind the server's Zod ^\d{5,9}$).
655
+ if (!AWARD_ID_RE.test(args.awardId)) {
656
+ throw new ToolErrorCarrier({
657
+ kind: "invalid_input",
658
+ message: `Invalid awardId ${JSON.stringify(args.awardId)} — expected an all-digit NSF award id (5–9 digits).`,
659
+ retryable: false,
660
+ upstreamEndpoint: NSF_LABEL,
661
+ });
662
+ }
663
+
664
+ const params = new URLSearchParams();
665
+ params.set("id", args.awardId);
666
+
667
+ const parsed = parseNsfBody(await nsfGet(params));
668
+ const { totalAvailable } = parsed;
669
+
670
+ const notes: string[] = [NSF_GRANT_CAVEAT, UEI_JOIN_NOTE, AMOUNTS_NOTE, DATA_CURRENCY_NOTE];
671
+
672
+ // Genuine-empty (totalCount:0, award:[]) ⇒ found:false (never a fabricated record).
673
+ if (parsed.rawAwards.length === 0 || totalAvailable === 0) {
674
+ return withMeta(
675
+ { found: false, award: null as NsfAward | null },
676
+ {
677
+ source: SOURCE,
678
+ keylessMode: true,
679
+ returned: 0,
680
+ totalAvailable: 0,
681
+ filtersApplied: ["awardId"],
682
+ filtersDropped: [],
683
+ fieldsUnavailable: [],
684
+ notes: [
685
+ `No NSF award has id ${JSON.stringify(args.awardId)} (found:false — an honest not-found, never a fabricated record).`,
686
+ ...notes,
687
+ ],
688
+ } satisfies Partial<ResponseMeta>,
689
+ );
690
+ }
691
+
692
+ const award = mapAward(parsed.rawAwards[0], true); // FULL record incl. abstractText
693
+ return withMeta(
694
+ { found: true, award },
695
+ {
696
+ source: SOURCE,
697
+ keylessMode: true,
698
+ returned: 1,
699
+ totalAvailable: 1,
700
+ filtersApplied: ["awardId"],
701
+ filtersDropped: [],
702
+ fieldsUnavailable: [],
703
+ notes,
704
+ } satisfies Partial<ResponseMeta>,
705
+ );
706
+ }