@cliwant/mcp-sam-gov 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
package/src/grants.ts CHANGED
@@ -1,155 +1,290 @@
1
- /**
2
- * Grants.gov v1 API wrappers (keyless).
3
- *
4
- * Grants.gov hosts federal financial-assistance opportunities (grants,
5
- * cooperative agreements). Distinct from SAM.gov contracts but the
6
- * same pursuit ICP often cares about both.
7
- *
8
- * Endpoints (POST JSON, no key):
9
- * - /v1/api/search2 — search opportunities
10
- * - /v1/api/fetchOpportunity — single grant detail
11
- *
12
- * Documented at https://grants.gov/web/grants/s2s/grantor/schemas/grants-search-2-soap.html
13
- */
14
-
15
- const GRANTS = "https://api.grants.gov/v1/api";
16
-
17
- async function postJson<T>(
18
- endpoint: string,
19
- body: Record<string, unknown>,
20
- ): Promise<T> {
21
- const r = await fetch(`${GRANTS}/${endpoint}`, {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: JSON.stringify(body),
25
- signal: AbortSignal.timeout(15_000),
26
- });
27
- if (!r.ok) {
28
- throw new Error(`Grants.gov ${endpoint} returned ${r.status}`);
29
- }
30
- return (await r.json()) as T;
31
- }
32
-
33
- export type GrantStatus = "forecasted" | "posted" | "closed" | "archived";
34
-
35
- export async function searchGrants(args: {
36
- keyword?: string;
37
- cfda?: string; // CFDA program number, e.g. "10.500"
38
- agency?: string; // agency code, e.g. "DHS-FEMA"
39
- oppNum?: string; // opportunity number
40
- oppStatuses?: GrantStatus[];
41
- rows?: number;
42
- }) {
43
- const body: Record<string, unknown> = {
44
- rows: args.rows ?? 10,
45
- keyword: args.keyword ?? "",
46
- cfda: args.cfda ?? "",
47
- agencies: args.agency ?? "",
48
- oppNum: args.oppNum ?? "",
49
- oppStatuses: (args.oppStatuses ?? ["forecasted", "posted"]).join("|"),
50
- };
51
- type Resp = {
52
- errorcode?: number;
53
- msg?: string;
54
- data?: {
55
- hitCount?: number;
56
- oppHits?: {
57
- id?: string;
58
- number?: string;
59
- title?: string;
60
- agencyCode?: string;
61
- agencyName?: string;
62
- openDate?: string;
63
- closeDate?: string;
64
- oppStatus?: string;
65
- docType?: string;
66
- cfdaList?: string;
67
- }[];
68
- };
69
- };
70
- const json = await postJson<Resp>("search2", body);
71
- if (json.errorcode && json.errorcode !== 0) {
72
- throw new Error(`Grants.gov error: ${json.msg ?? "unknown"}`);
73
- }
74
- return {
75
- totalRecords: json.data?.hitCount ?? 0,
76
- grants: (json.data?.oppHits ?? []).map((g) => ({
77
- id: g.id ?? "",
78
- opportunityNumber: g.number ?? "",
79
- title: g.title ?? "",
80
- agencyCode: g.agencyCode ?? "",
81
- agencyName: g.agencyName ?? "",
82
- openDate: g.openDate,
83
- closeDate: g.closeDate,
84
- status: g.oppStatus,
85
- docType: g.docType,
86
- cfdaList: g.cfdaList,
87
- })),
88
- };
89
- }
90
-
91
- export async function getGrant(args: { opportunityId: string }) {
92
- type Resp = {
93
- errorcode?: number;
94
- msg?: string;
95
- data?: {
96
- id?: number;
97
- opportunityNumber?: string;
98
- opportunityTitle?: string;
99
- owningAgencyCode?: string;
100
- synopsisDesc?: string;
101
- synopsis?: {
102
- synopsisDesc?: string;
103
- applicantTypes?: { description?: string }[];
104
- fundingActivityCategories?: { description?: string }[];
105
- fundingInstruments?: { description?: string }[];
106
- responseDate?: string;
107
- postingDate?: string;
108
- archiveDate?: string;
109
- awardCeiling?: number;
110
- awardFloor?: number;
111
- estimatedFunding?: number;
112
- expectedNumberOfAwards?: number;
113
- agencyName?: string;
114
- agencyCode?: string;
115
- };
116
- opportunityHistoryDetails?: { actionType?: string; actionDate?: string }[];
117
- cfdas?: { cfdaNumber?: string; programTitle?: string }[];
118
- };
119
- };
120
- const json = await postJson<Resp>("fetchOpportunity", {
121
- opportunityId: args.opportunityId,
122
- });
123
- if (json.errorcode && json.errorcode !== 0) {
124
- throw new Error(`Grants.gov error: ${json.msg ?? "unknown"}`);
125
- }
126
- const d = json.data ?? {};
127
- const s = d.synopsis ?? {};
128
- return {
129
- id: d.id ?? 0,
130
- opportunityNumber: d.opportunityNumber ?? "",
131
- title: d.opportunityTitle ?? "",
132
- agency: { code: s.agencyCode ?? d.owningAgencyCode, name: s.agencyName },
133
- description: s.synopsisDesc ?? d.synopsisDesc ?? "",
134
- postingDate: s.postingDate,
135
- responseDate: s.responseDate,
136
- archiveDate: s.archiveDate,
137
- awardCeiling: s.awardCeiling,
138
- awardFloor: s.awardFloor,
139
- estimatedFunding: s.estimatedFunding,
140
- expectedNumberOfAwards: s.expectedNumberOfAwards,
141
- applicantTypes: (s.applicantTypes ?? [])
142
- .map((a) => a.description)
143
- .filter(Boolean) as string[],
144
- fundingInstruments: (s.fundingInstruments ?? [])
145
- .map((f) => f.description)
146
- .filter(Boolean) as string[],
147
- fundingCategories: (s.fundingActivityCategories ?? [])
148
- .map((f) => f.description)
149
- .filter(Boolean) as string[],
150
- cfdaPrograms: (d.cfdas ?? []).map((c) => ({
151
- number: c.cfdaNumber ?? "",
152
- title: c.programTitle ?? "",
153
- })),
154
- };
155
- }
1
+ /**
2
+ * Grants.gov v1 API wrappers (keyless).
3
+ *
4
+ * Grants.gov hosts federal financial-assistance opportunities (grants,
5
+ * cooperative agreements). Distinct from SAM.gov contracts but the
6
+ * same pursuit ICP often cares about both.
7
+ *
8
+ * Endpoints (POST JSON, no key):
9
+ * - /v1/api/search2 — search opportunities
10
+ * - /v1/api/fetchOpportunity — single grant detail
11
+ *
12
+ * Documented at https://grants.gov/web/grants/s2s/grantor/schemas/grants-search-2-soap.html
13
+ */
14
+
15
+ import { fetchWithRetry } from "./errors.js";
16
+ import { driftError } from "./datasource.js";
17
+ import { withMeta } from "./meta.js";
18
+
19
+ const GRANTS = "https://api.grants.gov/v1/api";
20
+
21
+ async function postJson<T>(
22
+ endpoint: string,
23
+ body: Record<string, unknown>,
24
+ ): Promise<T> {
25
+ const r = await fetchWithRetry(
26
+ `${GRANTS}/${endpoint}`,
27
+ {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify(body),
31
+ signal: AbortSignal.timeout(15_000),
32
+ },
33
+ `grants.gov:${endpoint}`,
34
+ );
35
+ return (await r.json()) as T;
36
+ }
37
+
38
+ export type GrantStatus = "forecasted" | "posted" | "closed" | "archived";
39
+
40
+ export async function searchGrants(args: {
41
+ keyword?: string;
42
+ cfda?: string; // CFDA program number, e.g. "10.500"
43
+ agency?: string; // agency code, e.g. "DHS-FEMA"
44
+ oppNum?: string; // opportunity number
45
+ oppStatuses?: GrantStatus[];
46
+ rows?: number;
47
+ }) {
48
+ const body: Record<string, unknown> = {
49
+ rows: args.rows ?? 10,
50
+ keyword: args.keyword ?? "",
51
+ cfda: args.cfda ?? "",
52
+ agencies: args.agency ?? "",
53
+ oppNum: args.oppNum ?? "",
54
+ oppStatuses: (args.oppStatuses ?? ["forecasted", "posted"]).join("|"),
55
+ };
56
+ type Resp = {
57
+ errorcode?: number;
58
+ msg?: string;
59
+ data?: {
60
+ hitCount?: number;
61
+ oppHits?: {
62
+ id?: string;
63
+ number?: string;
64
+ title?: string;
65
+ agencyCode?: string;
66
+ // GRANT-2 (live-verified 2026-07-06): search2 returns the real agency NAME
67
+ // in `agency` (e.g. "Food and Nutrition Service"); `agencyName` is
68
+ // empty/absent on the search rows. Type both; the mapping prefers `agency`.
69
+ agency?: string;
70
+ agencyName?: string;
71
+ openDate?: string;
72
+ closeDate?: string;
73
+ oppStatus?: string;
74
+ docType?: string;
75
+ // E-2 (spec §1.6, §5): the Grants.gov search2 response returns
76
+ // cfdaList as an ARRAY of CFDA numbers (e.g. ["19.441"]) NOT a
77
+ // pipe-delimited string as the type previously declared. Correct the
78
+ // type to match the runtime shape.
79
+ cfdaList?: string[];
80
+ }[];
81
+ };
82
+ };
83
+ const json = await postJson<Resp>("search2", body);
84
+ if (json.errorcode && json.errorcode !== 0) {
85
+ throw new Error(`Grants.gov error: ${json.msg ?? "unknown"}`);
86
+ }
87
+ // F4 (P2 empty-vs-outage): a 200 that is NEITHER a recognized error envelope
88
+ // (handled above) NOR a `{data:{hitCount:number,…}}` shape is drift — a
89
+ // malformed / outage / interstitial body — and must NOT coalesce to
90
+ // `totalRecords:0` (an AUTHORITATIVE "no grants"). A GENUINE empty carries a
91
+ // numeric `data.hitCount:0` and stays an honest empty below. (Mirrors the
92
+ // govinfo/nih driftError precedent — a drift is a throw, never a fake-empty.)
93
+ const hitCount = json.data?.hitCount;
94
+ if (typeof hitCount !== "number") {
95
+ throw driftError(
96
+ "grants.gov",
97
+ "Grants.gov search2 returned HTTP 200 but the body carries neither an error code nor a numeric data.hitCount — treating it as schema drift / an outage interstitial, NOT an empty result set.",
98
+ );
99
+ }
100
+ const totalRecords = hitCount;
101
+ const data = {
102
+ totalRecords,
103
+ grants: (json.data?.oppHits ?? []).map((g) => ({
104
+ id: g.id ?? "",
105
+ opportunityNumber: g.number ?? "",
106
+ title: g.title ?? "",
107
+ agencyCode: g.agencyCode ?? "",
108
+ // GRANT-2: prefer `agency` (the real agency name) over the empty `agencyName`.
109
+ agencyName: g.agency ?? g.agencyName ?? "",
110
+ openDate: g.openDate,
111
+ closeDate: g.closeDate,
112
+ status: g.oppStatus,
113
+ docType: g.docType,
114
+ // E-2: always an array — normalize absent/legacy-string to []/[str]
115
+ // so consumers can rely on `cfdaList` being string[] unconditionally.
116
+ cfdaList: Array.isArray(g.cfdaList)
117
+ ? g.cfdaList
118
+ : g.cfdaList
119
+ ? [g.cfdaList as unknown as string]
120
+ : [],
121
+ })),
122
+ };
123
+
124
+ // Truthful `_meta` (spec §1.2 A4, §2.3). Grants.gov reports a real match
125
+ // total (`hitCount`), so `totalAvailable` is trustworthy. A4: Grants.gov
126
+ // silently IGNORES unknown agency codes / CFDA numbers (it returns the
127
+ // unfiltered set rather than erroring), and it does not echo back which
128
+ // filters it honored — so the AI cannot verify a filter took. We list the
129
+ // filters we SENT in `filtersApplied` and warn that unknown values are
130
+ // dropped silently, so the AI treats a suspiciously large result set with
131
+ // caution instead of asserting it is filtered.
132
+ const returned = data.grants.length;
133
+ const sent: string[] = [];
134
+ if (args.keyword) sent.push("keyword");
135
+ if (args.cfda) sent.push("cfda");
136
+ if (args.agency) sent.push("agency");
137
+ if (args.oppNum) sent.push("oppNum");
138
+ if (args.oppStatuses?.length) sent.push("oppStatuses");
139
+ const notes: string[] = [];
140
+ if (args.agency || args.cfda) {
141
+ notes.push(
142
+ "Grants.gov silently ignores an unknown agency code or CFDA number (it returns the UNFILTERED result set instead of an error) and does not confirm which filters were honored — if the result count looks too broad, verify the agency/CFDA value.",
143
+ );
144
+ }
145
+ // VQ-1 (C82 dogfooding): Grants.gov OR-tokenizes multi-word keywords (matches ANY
146
+ // word) and does NOT support phrase quoting (a quoted "..." keyword returns 0). Live:
147
+ // "cybersecurity information technology" → 926 broad hits (State-Dept program
148
+ // statements top) vs 280 focused for "cybersecurity". The note is RESULT-AWARE
149
+ // (adversarial review F1/F2): "broad set ≠ no grants" only holds when there ARE
150
+ // results; a 0-result multi-word query needs the opposite advice. Quotes are stripped
151
+ // for the word count so a quote-wrapped phrase is still detected as multi-word.
152
+ const rawKw = args.keyword ?? "";
153
+ const looksQuoted = /^\s*["'][\s\S]*["']\s*$/.test(rawKw);
154
+ const kwWords = rawKw
155
+ .trim()
156
+ .replace(/^["']+|["']+$/g, "")
157
+ .trim()
158
+ // disclosure-split-ok: grants.gov OR-splits ONLY whitespace (and '+'), NOT punctuation — live-verified 2026-07; the shared DISCLOSURE_SPLIT_RE punctuation class must NOT be applied here (it would over-disclose). See ADR-0022.
159
+ .split(/\s+/)
160
+ .filter(Boolean);
161
+ if (kwWords.length > 1) {
162
+ if (totalRecords > 0) {
163
+ notes.push(
164
+ `Grants.gov OR-matches multi-word keywords: it returns opportunities containing ANY of these ${kwWords.length} words, so this keyword may BROADEN results if any word is a common term ("information"/"technology"/"program" match many unrelated grants) — a broad, poorly-ranked set here does NOT mean "no relevant grants". For focused results pass ONE specific term (e.g. "cybersecurity"); Grants.gov does NOT support phrase quoting (a quoted "..." keyword returns 0). Narrow with cfda / agency / oppStatuses instead.`,
165
+ );
166
+ } else {
167
+ notes.push(
168
+ `This ${kwWords.length}-word keyword returned 0 results. Grants.gov OR-matches keywords and does NOT support phrase quoting${looksQuoted ? ' — your keyword appears quote-wrapped, so REMOVE the quotes (a quoted "..." keyword always returns 0)' : ' (a quoted "..." keyword returns 0)'}. A 0 here means even the OR of these words has no match — try each word separately, or narrow with cfda/agency/oppStatuses, to isolate.`,
169
+ );
170
+ }
171
+ }
172
+ return withMeta(data, {
173
+ source: "grants.gov/api (search2)",
174
+ keylessMode: true,
175
+ returned,
176
+ totalAvailable: totalRecords,
177
+ truncated: returned < totalRecords,
178
+ filtersApplied: sent,
179
+ filtersDropped: [],
180
+ fieldsUnavailable: [],
181
+ notes,
182
+ });
183
+ }
184
+
185
+ export async function getGrant(args: { opportunityId: string }) {
186
+ type Resp = {
187
+ errorcode?: number;
188
+ msg?: string;
189
+ data?: {
190
+ id?: number;
191
+ opportunityNumber?: string;
192
+ opportunityTitle?: string;
193
+ owningAgencyCode?: string;
194
+ synopsisDesc?: string;
195
+ // GRANT-2: fetchOpportunity carries agencyDetails/topAgencyDetails at the TOP
196
+ // level too (sibling to synopsis) — a sparse-synopsis (e.g. forecasted) record
197
+ // may have them here but not under synopsis, so they are in the fallback chain.
198
+ agencyDetails?: { agencyName?: string; agencyCode?: string };
199
+ topAgencyDetails?: { agencyName?: string; agencyCode?: string };
200
+ synopsis?: {
201
+ synopsisDesc?: string;
202
+ applicantTypes?: { description?: string }[];
203
+ fundingActivityCategories?: { description?: string }[];
204
+ fundingInstruments?: { description?: string }[];
205
+ responseDate?: string;
206
+ postingDate?: string;
207
+ archiveDate?: string;
208
+ awardCeiling?: number;
209
+ awardFloor?: number;
210
+ estimatedFunding?: number;
211
+ expectedNumberOfAwards?: number;
212
+ agencyName?: string;
213
+ agencyCode?: string;
214
+ // GRANT-2 (live-verified 2026-07-06): synopsis.agencyName is the CONTACT
215
+ // PERSON (== agencyContactName, e.g. "Andrew Day\nGrants/Agreements
216
+ // Officer"), NOT the agency. The real agency name lives in agencyDetails
217
+ // (subtier, e.g. "Food and Nutrition Service") and topAgencyDetails
218
+ // (department, e.g. "Department of Agriculture").
219
+ agencyContactName?: string;
220
+ agencyDetails?: { agencyName?: string; agencyCode?: string };
221
+ topAgencyDetails?: { agencyName?: string; agencyCode?: string };
222
+ };
223
+ opportunityHistoryDetails?: { actionType?: string; actionDate?: string }[];
224
+ cfdas?: { cfdaNumber?: string; programTitle?: string }[];
225
+ };
226
+ };
227
+ const json = await postJson<Resp>("fetchOpportunity", {
228
+ opportunityId: args.opportunityId,
229
+ });
230
+ if (json.errorcode && json.errorcode !== 0) {
231
+ throw new Error(`Grants.gov error: ${json.msg ?? "unknown"}`);
232
+ }
233
+ const d = json.data ?? {};
234
+ // NOT-FOUND: Grants.gov's fetchOpportunity returns errorcode:0 ("Webservice
235
+ // Succeeds") + a HOLLOW `data` object even for a NONEXISTENT opportunityId
236
+ // (LIVE-VERIFIED 2026-07-06: id 999999999 → errorcode 0 but data carries NO
237
+ // id / opportunityNumber / opportunityTitle / synopsis — only skeleton fields).
238
+ // Mapping that shell would fabricate a grant (id:0, title:"") for an opportunity
239
+ // that does NOT exist — an absence-as-present lie. `id` is the reliable signal:
240
+ // a real grant ALWAYS carries a numeric `id`, the hollow shell never does.
241
+ if (d.id === undefined || d.id === null) {
242
+ return { found: false as const, opportunityId: args.opportunityId };
243
+ }
244
+ const s = d.synopsis ?? {};
245
+ const rawContact = s.agencyContactName ?? s.agencyName ?? null;
246
+ return {
247
+ found: true as const,
248
+ id: d.id, // guaranteed present past the not-found guard above
249
+
250
+ opportunityNumber: d.opportunityNumber ?? "",
251
+ title: d.opportunityTitle ?? "",
252
+ // GRANT-2: `name` is the REAL agency (subtier preferred, else the department),
253
+ // NOT synopsis.agencyName (which is the contact person). Fall back through both
254
+ // the synopsis and top-level `data` locations. `department` is the top-tier
255
+ // agency; `contactName` preserves the person the mislabeled old `name` held
256
+ // (newlines collapsed so it renders on one line).
257
+ agency: {
258
+ code: s.agencyCode ?? d.owningAgencyCode,
259
+ name:
260
+ s.agencyDetails?.agencyName ??
261
+ d.agencyDetails?.agencyName ??
262
+ s.topAgencyDetails?.agencyName ??
263
+ d.topAgencyDetails?.agencyName ??
264
+ null,
265
+ department: s.topAgencyDetails?.agencyName ?? d.topAgencyDetails?.agencyName ?? null,
266
+ contactName: rawContact ? rawContact.replace(/\s*\n\s*/g, " — ") : null,
267
+ },
268
+ description: s.synopsisDesc ?? d.synopsisDesc ?? "",
269
+ postingDate: s.postingDate,
270
+ responseDate: s.responseDate,
271
+ archiveDate: s.archiveDate,
272
+ awardCeiling: s.awardCeiling,
273
+ awardFloor: s.awardFloor,
274
+ estimatedFunding: s.estimatedFunding,
275
+ expectedNumberOfAwards: s.expectedNumberOfAwards,
276
+ applicantTypes: (s.applicantTypes ?? [])
277
+ .map((a) => a.description)
278
+ .filter(Boolean) as string[],
279
+ fundingInstruments: (s.fundingInstruments ?? [])
280
+ .map((f) => f.description)
281
+ .filter(Boolean) as string[],
282
+ fundingCategories: (s.fundingActivityCategories ?? [])
283
+ .map((f) => f.description)
284
+ .filter(Boolean) as string[],
285
+ cfdaPrograms: (d.cfdas ?? []).map((c) => ({
286
+ number: c.cfdaNumber ?? "",
287
+ title: c.programTitle ?? "",
288
+ })),
289
+ };
290
+ }