@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.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
}
|