@pipeworx/mcp-grants-gov 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -2,16 +2,32 @@
2
2
 
3
3
  Grants.gov MCP — open federal funding opportunities, no auth.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1683+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1686+ live data sources.
6
6
 
7
7
  ## Tools
8
8
 
9
+ Live (proxies Grants.gov's search2 API — currently-open coverage only):
10
+
9
11
  - `search_opportunities(keyword?, status?, agencies?, funding_categories?, aln?, limit?, offset?)` — filter open opportunities.
10
12
  - `get_opportunity(opportunity_id)` — full record with synopsis, eligibility, attachments.
11
13
 
12
- ## Data source
14
+ Local mirror (fleet #2512 — closed, archived and forecasted history the live API cannot serve):
15
+
16
+ - `search_historical_opportunities(agency_code?, cfda?, keyword?, status?, opportunity_category?, post_date_from?, post_date_to?, limit?, offset?)` — search the FULL extract, every status, back to 2004.
17
+ - `get_historical_opportunity(opportunity_id)` — one record, any status, with a computed `status`.
18
+ - `agency_posting_trend(agency_code, years?)` — an agency's opportunity count / funding by federal fiscal year.
19
+
20
+ ## Data sources
13
21
 
14
- `https://api.grants.gov/v1/api/` — public POST-JSON API, no key required.
22
+ - Live: `https://api.grants.gov/v1/api/` — public POST-JSON API, no key required.
23
+ - Mirror: Grants.gov's own daily full-catalog XML extract (`grants.gov/xml-extract` ->
24
+ `prod-grants-gov-chatbot.s3.amazonaws.com/extracts/GrantsDBExtract<YYYYMMDD>v2.zip`,
25
+ no auth). Loaded into Supabase `grants_gov_opportunities`
26
+ (`supabase/migrations/226_grants_gov_extract.sql`) by
27
+ `scripts/grants-gov-upsert.sh` via `.github/workflows/grants-gov-refresh.yml`
28
+ (`workflow_dispatch` for now — see that workflow for why no `schedule:` is
29
+ wired yet). Every mirror response carries `data_as_of`, the last successful
30
+ refresh.
15
31
 
16
32
  ## Quick Start
17
33
 
@@ -57,7 +73,7 @@ directly, instead of just this one's:
57
73
  }
58
74
  ```
59
75
 
60
- Both URLs reach the same gateway and the same 1683+ data sources. The
76
+ Both URLs reach the same gateway and the same 1686+ data sources. The
61
77
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
62
78
  reaches all of them from either one.
63
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-grants-gov",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Grants.gov MCP — open federal grant opportunities (free, no auth)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-1846b32008e4c510b8dde408336adacab39956e0b19cc5caaf9c36b82d53b018",
30
- "sourceCommit": "1064560fd9dcd0d6fc2606298c2ba244b3a1fbeb"
29
+ "sourceHash": "v1-ed594888eaeec8bf4a0e21bd489348e332b301028709ac493d6709e0557847d1",
30
+ "sourceCommit": "658bedc4e66c6eab7e17c569f81012a2ee312624"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/grants-gov",
4
4
  "title": "Grants Gov",
5
5
  "description": "Grants.gov MCP — open federal grant opportunities (free, no auth)",
6
- "version": "0.1.3",
6
+ "version": "0.1.4",
7
7
  "websiteUrl": "https://pipeworx.io/packs/grants-gov",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-grants-gov",
package/src/index.ts CHANGED
@@ -677,9 +677,38 @@ function collapse(s: string): string {
677
677
  * the federal-wide clearinghouse for currently-open and forecasted opportunities.
678
678
  *
679
679
  * API: https://www.grants.gov/web/grants/s2s/grantor/schemas/grants-gov-search2.html
680
- * Tools:
680
+ * Tools (live proxy, currently-open coverage only):
681
681
  * - search_opportunities: filter by keyword, status, agency, funding category, dates
682
682
  * - get_opportunity: single full record with attachments and synopsis text
683
+ *
684
+ * Tools (local mirror, fleet #2512 — closed/archived/forecasted history the
685
+ * live API structurally cannot serve):
686
+ * - search_historical_opportunities: agency/CFDA/keyword/status (incl. closed
687
+ * and forecasted)/date-range search over the FULL extract, back to 2004.
688
+ * - get_historical_opportunity: one record from the mirror, any status.
689
+ * - agency_posting_trend: an agency's opportunity count / funding
690
+ * by fiscal year, derived from post_date.
691
+ *
692
+ * WHY A MIRROR FOR HISTORY: search2 (BASE_URL below) is Grants.gov's live
693
+ * index and only usefully answers "what is open or about to open right
694
+ * now" — there is no live endpoint for "what did this agency post five
695
+ * fiscal years ago" or "what was forecasted but never opened". Per Bruce's
696
+ * 2026-09-23 ruling ("build a copy when live doesn't serve"), the historical
697
+ * tools below read migration 226's grants_gov_opportunities, loaded from
698
+ * Grants.gov's own daily full-catalog XML export
699
+ * (grants.gov/xml-extract -> prod-grants-gov-chatbot.s3.amazonaws.com,
700
+ * verified live 2026-09-30: 83,497 records, no auth) by
701
+ * scripts/grants-gov-upsert.sh via .github/workflows/grants-gov-refresh.yml.
702
+ * `data_as_of` is on every mirror response (the last successful refresh,
703
+ * read from grants_gov_ingest_runs via the grants_gov_coverage() RPC).
704
+ * The live tools (search_opportunities/get_opportunity) are UNCHANGED —
705
+ * they still proxy search2 for the "what's open right now" case, which the
706
+ * mirror (refreshed once daily) is never quite fresh enough for.
707
+ *
708
+ * NO NEW PERSONAL DATA: the mirror carries GrantorContactName/Text, an
709
+ * agency's published point-of-contact for the opportunity — the same class
710
+ * of disclosure get_opportunity's live GrantorContact* fields already
711
+ * surface; see the migration file for the full reasoning.
683
712
  */
684
713
 
685
714
 
@@ -740,6 +769,63 @@ const tools: McpToolExport['tools'] = [
740
769
  required: ['opportunity_id'],
741
770
  },
742
771
  },
772
+ {
773
+ name: 'search_historical_opportunities',
774
+ description:
775
+ 'Search Grants.gov\'s FULL opportunity history (closed and archived opportunities back to 2004, plus forecasted opportunities not yet posted) — coverage search_opportunities\' live API cannot serve, since search2 only usefully answers "what is open right now". Covers Grants.gov\'s full catalog, refreshed once a day (see `data_as_of`). Filter by agency code, CFDA/Assistance Listing number, keyword (full-text over title + description), status, opportunity category, or post-date range. Unlike search_opportunities, status here defaults to EVERY status, not just "posted" — pass status explicitly to narrow. Answers "what did this agency fund last fiscal year", "what opportunities under CFDA 93.351 have ever closed", "what is forecasted for next year that hasn\'t posted yet".',
776
+ inputSchema: {
777
+ type: 'object',
778
+ properties: {
779
+ agency_code: {
780
+ type: 'string',
781
+ description: 'Agency or sub-agency code, e.g. "NIH" or "HHS-NIH11" or "DOS". Matches as a prefix/segment against the fully-qualified codes on file (same bare-vs-qualified forgiveness as search_opportunities\' `agencies`). Alias: agency.',
782
+ },
783
+ agency: { type: 'string', description: 'Alias for agency_code.' },
784
+ cfda: { type: 'string', description: 'Assistance Listing Number / CFDA number, exact match, e.g. "93.351". Alias: aln.' },
785
+ aln: { type: 'string', description: 'Alias for cfda.' },
786
+ keyword: { type: 'string', description: 'Full-text search over the opportunity title and description (PostgreSQL websearch syntax — quote a phrase, "-word" to exclude). More forgiving than the live tool\'s exact-phrase keyword match.' },
787
+ status: {
788
+ type: 'string',
789
+ description: 'posted | closed | archived | forecasted. Omit to search every status, unlike the live tool (whose default is "posted").',
790
+ },
791
+ opportunity_category: { type: 'string', description: 'Single-letter Grants.gov category code: D discretionary, M mandatory, C continuation, E earmark, O other.' },
792
+ post_date_from: { type: 'string', description: 'Only opportunities posted on/after this date, YYYY-MM-DD.' },
793
+ post_date_to: { type: 'string', description: 'Only opportunities posted on/before this date, YYYY-MM-DD.' },
794
+ limit: { type: 'number', description: 'Rows to return, 1-500 (default 25).' },
795
+ offset: { type: 'number', description: '0-based start record, for paging.' },
796
+ },
797
+ required: [],
798
+ },
799
+ },
800
+ {
801
+ name: 'get_historical_opportunity',
802
+ description:
803
+ 'Fetch one Grants.gov opportunity by its numeric opportunity ID, in ANY status (open, closed, archived, or forecasted) — unlike get_opportunity, which calls the live API and only reliably resolves currently-open opportunities. Returns the full record plus a computed `status` (derived from close/archive dates against today, the same vocabulary search_historical_opportunities uses) and `data_as_of`.',
804
+ inputSchema: {
805
+ type: 'object',
806
+ properties: {
807
+ opportunity_id: {
808
+ type: 'number',
809
+ description: 'Numeric opportunity ID (returned by search_historical_opportunities or search_opportunities as "id").',
810
+ },
811
+ },
812
+ required: ['opportunity_id'],
813
+ },
814
+ },
815
+ {
816
+ name: 'agency_posting_trend',
817
+ description:
818
+ 'An agency\'s opportunity-posting trend by federal fiscal year (Oct-Sep): how many opportunities it posted, total estimated program funding, and average award ceiling, per year. Answers "is this agency posting more or fewer opportunities than last year" and "how has this agency\'s funding trended". Fiscal year is derived from each opportunity\'s post_date.',
819
+ inputSchema: {
820
+ type: 'object',
821
+ properties: {
822
+ agency_code: { type: 'string', description: 'Agency or sub-agency code, e.g. "EPA", "NIH", "HHS-CDC1". Matches as a prefix/segment, same forgiveness as search_historical_opportunities. Alias: agency.' },
823
+ agency: { type: 'string', description: 'Alias for agency_code.' },
824
+ years: { type: 'number', description: 'Number of most-recent fiscal years to return, 1-40 (default 10).' },
825
+ },
826
+ required: [],
827
+ },
828
+ },
743
829
  ];
744
830
 
745
831
  function reqNum(args: Record<string, unknown>, key: string, example: string): number {
@@ -751,7 +837,12 @@ function reqNum(args: Record<string, unknown>, key: string, example: string): nu
751
837
  return n;
752
838
  }
753
839
 
840
+ const MIRROR_TOOLS = new Set(['search_historical_opportunities', 'get_historical_opportunity', 'agency_posting_trend']);
841
+
754
842
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
843
+ if (MIRROR_TOOLS.has(name)) {
844
+ return callMirrorTool(name, args);
845
+ }
755
846
  switch (name) {
756
847
  case 'search_opportunities':
757
848
  return searchOpps(args);
@@ -1010,4 +1101,234 @@ function normalizeDetail(d: OpportunityDetail) {
1010
1101
  };
1011
1102
  }
1012
1103
 
1104
+ /* ----------------------------------------------------- mirror (local DB) */
1105
+
1106
+ interface DbConfig {
1107
+ url: string;
1108
+ key: string;
1109
+ }
1110
+
1111
+ async function sbRpc(cfg: DbConfig, fn: string, body: Record<string, unknown>): Promise<unknown[]> {
1112
+ const url = `${cfg.url}/rest/v1/rpc/${fn}`;
1113
+ const res = await pwFetch(url, {
1114
+ method: 'POST',
1115
+ headers: {
1116
+ apikey: cfg.key,
1117
+ Authorization: `Bearer ${cfg.key}`,
1118
+ 'Content-Type': 'application/json',
1119
+ Prefer: 'params=single-object',
1120
+ },
1121
+ body: JSON.stringify(body),
1122
+ });
1123
+ if (!res.ok) throw await httpError(res, 'Grants.gov historical data');
1124
+ const parsed = await res.json();
1125
+ if (!Array.isArray(parsed)) {
1126
+ throw new Error('Grants.gov historical data: RPC did not return a row array — treating this as a failure.');
1127
+ }
1128
+ return parsed;
1129
+ }
1130
+
1131
+ let cachedDataAsOf: { value: string | null; coverage: Record<string, unknown> | null } | undefined;
1132
+
1133
+ /** The last successful refresh + coverage stats, read once per Worker instance and reused across calls. */
1134
+ async function mirrorCoverage(cfg: DbConfig): Promise<{ value: string | null; coverage: Record<string, unknown> | null }> {
1135
+ if (cachedDataAsOf !== undefined) return cachedDataAsOf;
1136
+ try {
1137
+ const rows = (await sbRpc(cfg, 'grants_gov_coverage', {})) as Record<string, unknown>[];
1138
+ const row = rows[0] ?? null;
1139
+ cachedDataAsOf = { value: (row?.data_as_of as string | undefined) ?? null, coverage: row };
1140
+ } catch {
1141
+ cachedDataAsOf = { value: null, coverage: null }; // freshness is metadata, never the reason a call fails
1142
+ }
1143
+ return cachedDataAsOf;
1144
+ }
1145
+
1146
+ function withDataAsOf<T extends Record<string, unknown>>(result: T, asOf: string | null): T & { data_as_of: string | null } {
1147
+ return {
1148
+ ...result,
1149
+ data_as_of: asOf,
1150
+ ...(asOf
1151
+ ? {}
1152
+ : {
1153
+ coverage_status:
1154
+ 'The Grants.gov historical dataset has not completed its first load yet — results may be empty or incomplete. This is a deployment state, not a query problem.',
1155
+ }),
1156
+ };
1157
+ }
1158
+
1159
+ function agencyArg(args: Record<string, unknown>): string | undefined {
1160
+ const raw = (args.agency_code ?? args.agency) as string | undefined;
1161
+ return raw ? String(raw).trim() : undefined;
1162
+ }
1163
+
1164
+ interface MirrorSearchRow {
1165
+ opportunity_id: number;
1166
+ opportunity_title: string | null;
1167
+ opportunity_number: string | null;
1168
+ agency_code: string | null;
1169
+ agency_name: string | null;
1170
+ status: string | null;
1171
+ post_date: string | null;
1172
+ close_date: string | null;
1173
+ fiscal_year: number | null;
1174
+ cfda_numbers: string[] | null;
1175
+ award_ceiling: number | null;
1176
+ estimated_total_program_funding: number | null;
1177
+ total_count: number;
1178
+ }
1179
+
1180
+ function shapeSearchRow(r: MirrorSearchRow) {
1181
+ return {
1182
+ id: r.opportunity_id,
1183
+ title: r.opportunity_title,
1184
+ number: r.opportunity_number,
1185
+ agency_code: r.agency_code,
1186
+ agency: r.agency_name,
1187
+ status: r.status,
1188
+ post_date: r.post_date,
1189
+ close_date: r.close_date,
1190
+ fiscal_year: r.fiscal_year,
1191
+ assistance_listing_numbers: r.cfda_numbers ?? [],
1192
+ award_ceiling: r.award_ceiling,
1193
+ estimated_total_program_funding: r.estimated_total_program_funding,
1194
+ grants_gov_url: `https://www.grants.gov/search-results-detail/${r.opportunity_id}`,
1195
+ };
1196
+ }
1197
+
1198
+ async function searchHistorical(cfg: DbConfig, args: Record<string, unknown>) {
1199
+ const limit = Math.min(500, Math.max(1, Number(args.limit) || 25));
1200
+ const offset = Math.max(0, Number(args.offset) || 0);
1201
+ const rows = (await sbRpc(cfg, 'grants_gov_search', {
1202
+ p_agency_code: agencyArg(args) ?? null,
1203
+ p_cfda: (args.cfda ?? args.aln) ? String(args.cfda ?? args.aln) : null,
1204
+ p_keyword: args.keyword ? String(args.keyword) : null,
1205
+ p_status: args.status ? String(args.status).toLowerCase() : null,
1206
+ p_opportunity_category: args.opportunity_category ? String(args.opportunity_category) : null,
1207
+ p_post_date_from: args.post_date_from ? String(args.post_date_from) : null,
1208
+ p_post_date_to: args.post_date_to ? String(args.post_date_to) : null,
1209
+ p_limit: limit,
1210
+ p_offset: offset,
1211
+ })) as MirrorSearchRow[];
1212
+
1213
+ return {
1214
+ total: rows.length ? rows[0].total_count : 0,
1215
+ returned: rows.length,
1216
+ offset,
1217
+ opportunities: rows.map(shapeSearchRow),
1218
+ source: 'Grants.gov\'s own full-catalog extract, refreshed daily — covers every status, not just currently-open (see search_opportunities for the live-only view).',
1219
+ };
1220
+ }
1221
+
1222
+ async function getHistorical(cfg: DbConfig, opportunityId: number) {
1223
+ const rows = (await sbRpc(cfg, 'grants_gov_get', { p_opportunity_id: opportunityId })) as Record<string, unknown>[];
1224
+ const row = rows[0];
1225
+ if (!row) {
1226
+ return {
1227
+ found: false,
1228
+ reason: `No opportunity with id ${opportunityId} in Grants.gov's historical dataset.`,
1229
+ hint: 'Check the id with search_historical_opportunities, or the id may belong to an opportunity type not yet loaded.',
1230
+ };
1231
+ }
1232
+ const status =
1233
+ row.record_type === 'forecast'
1234
+ ? 'forecasted'
1235
+ : !row.close_date || (row.close_date as string) >= new Date().toISOString().slice(0, 10)
1236
+ ? 'posted'
1237
+ : !row.archive_date || (row.archive_date as string) >= new Date().toISOString().slice(0, 10)
1238
+ ? 'closed'
1239
+ : 'archived';
1240
+ return {
1241
+ found: true,
1242
+ id: row.opportunity_id,
1243
+ record_type: row.record_type,
1244
+ status,
1245
+ title: row.opportunity_title,
1246
+ number: row.opportunity_number,
1247
+ category: row.opportunity_category,
1248
+ funding_instrument_type: row.funding_instrument_type,
1249
+ funding_activity_categories: row.category_of_funding_activity ?? [],
1250
+ assistance_listing_numbers: row.cfda_numbers ?? [],
1251
+ eligible_applicant_codes: row.eligible_applicant_codes ?? [],
1252
+ eligibility_note: row.additional_information_on_eligibility,
1253
+ agency_code: row.agency_code,
1254
+ agency: row.agency_name,
1255
+ post_date: row.post_date,
1256
+ close_date: row.close_date,
1257
+ archive_date: row.archive_date,
1258
+ last_updated_date: row.last_updated_date,
1259
+ award_ceiling: row.award_ceiling,
1260
+ award_floor: row.award_floor,
1261
+ estimated_total_program_funding: row.estimated_total_program_funding,
1262
+ expected_number_of_awards: row.expected_number_of_awards,
1263
+ description: row.description,
1264
+ cost_sharing_required: row.cost_sharing_or_matching_requirement,
1265
+ contact_name: row.grantor_contact_name,
1266
+ contact_email: row.grantor_contact_email,
1267
+ contact_phone: row.grantor_contact_phone_number,
1268
+ fiscal_year: row.fiscal_year,
1269
+ estimated_synopsis_post_date: row.estimated_synopsis_post_date,
1270
+ estimated_synopsis_close_date: row.estimated_synopsis_close_date,
1271
+ estimated_award_date: row.estimated_award_date,
1272
+ grants_gov_url: `https://www.grants.gov/search-results-detail/${row.opportunity_id}`,
1273
+ source: 'Grants.gov\'s own full-catalog extract, refreshed daily.',
1274
+ };
1275
+ }
1276
+
1277
+ interface TrendRow {
1278
+ fiscal_year: number;
1279
+ opportunities_posted: number;
1280
+ total_estimated_funding: number | null;
1281
+ avg_award_ceiling: number | null;
1282
+ }
1283
+
1284
+ async function agencyTrend(cfg: DbConfig, args: Record<string, unknown>) {
1285
+ const agency = agencyArg(args);
1286
+ if (!agency) {
1287
+ throw new Error('agency_posting_trend needs an agency code. Pass agency_code (or its alias agency), e.g. "EPA" or "HHS-NIH11".');
1288
+ }
1289
+ const years = Math.min(40, Math.max(1, Number(args.years) || 10));
1290
+ const rows = (await sbRpc(cfg, 'grants_gov_agency_trend', { p_agency_code: agency, p_years: years })) as TrendRow[];
1291
+ return {
1292
+ agency_code: agency,
1293
+ years_returned: rows.length,
1294
+ trend: rows.map((r) => ({
1295
+ fiscal_year: r.fiscal_year,
1296
+ opportunities_posted: r.opportunities_posted,
1297
+ total_estimated_funding: r.total_estimated_funding,
1298
+ avg_award_ceiling: r.avg_award_ceiling,
1299
+ })),
1300
+ source: 'Grants.gov\'s own full-catalog extract, refreshed daily; fiscal year derived from each opportunity\'s post_date (federal FY = Oct-Sep).',
1301
+ };
1302
+ }
1303
+
1304
+ async function callMirrorTool(name: string, args: Record<string, unknown>): Promise<unknown> {
1305
+ const url = (args._supabaseUrl as string | undefined)?.trim();
1306
+ const key = (args._supabaseKey as string | undefined)?.trim();
1307
+ if (!url || !key) {
1308
+ throw new Error(
1309
+ 'The Grants.gov historical dataset is not configured on this deployment — an operator must enable its data credentials. This is a setup problem, not your arguments. search_opportunities/get_opportunity (the live tools) do not need this and still work.',
1310
+ );
1311
+ }
1312
+ const cfg: DbConfig = { url, key };
1313
+ const asOf = await mirrorCoverage(cfg);
1314
+
1315
+ let result: unknown;
1316
+ switch (name) {
1317
+ case 'search_historical_opportunities':
1318
+ result = await searchHistorical(cfg, args);
1319
+ break;
1320
+ case 'get_historical_opportunity':
1321
+ result = await getHistorical(cfg, reqNum(args, 'opportunity_id', '363992 (numeric ID)'));
1322
+ break;
1323
+ case 'agency_posting_trend':
1324
+ result = await agencyTrend(cfg, args);
1325
+ break;
1326
+ default:
1327
+ throw new Error(`Unknown tool: ${name}`);
1328
+ }
1329
+ return result && typeof result === 'object' && !Array.isArray(result)
1330
+ ? withDataAsOf(result as Record<string, unknown>, asOf.value)
1331
+ : result;
1332
+ }
1333
+
1013
1334
  export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
9
9
  import pack from './index.js';
10
10
 
11
11
  const server = new Server(
12
- { name: '@pipeworx/mcp-grants-gov', version: '0.1.3' },
12
+ { name: '@pipeworx/mcp-grants-gov', version: '0.1.4' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15