@pipeworx/mcp-grants-gov 0.1.2 → 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 +20 -4
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +462 -13
- package/src/server.ts +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
+
"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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
|
|
|
452
452
|
),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
456
491
|
}
|
|
457
492
|
}
|
|
458
493
|
|
|
@@ -642,9 +677,38 @@ function collapse(s: string): string {
|
|
|
642
677
|
* the federal-wide clearinghouse for currently-open and forecasted opportunities.
|
|
643
678
|
*
|
|
644
679
|
* API: https://www.grants.gov/web/grants/s2s/grantor/schemas/grants-gov-search2.html
|
|
645
|
-
* Tools:
|
|
680
|
+
* Tools (live proxy, currently-open coverage only):
|
|
646
681
|
* - search_opportunities: filter by keyword, status, agency, funding category, dates
|
|
647
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.
|
|
648
712
|
*/
|
|
649
713
|
|
|
650
714
|
|
|
@@ -667,14 +731,18 @@ const tools: McpToolExport['tools'] = [
|
|
|
667
731
|
inputSchema: {
|
|
668
732
|
type: 'object',
|
|
669
733
|
properties: {
|
|
670
|
-
keyword: {
|
|
734
|
+
keyword: {
|
|
735
|
+
type: 'string',
|
|
736
|
+
description:
|
|
737
|
+
'Grants.gov\'s own keyword match behaves like an exact/AND phrase across title and synopsis text, not a relevance-ranked full-text search — a compound multi-concept phrase (e.g. joining three unrelated nouns) routinely matches ZERO opportunities even when relevant ones are open (verified live 2026-09-25, fleet #2450: "cancer liquid biopsy diagnostic" -> 0 hits; the single word "SBIR" -> 29 hits including the exact NIH SBIR/STTR hub the compound phrase was trying to find). Prefer ONE short, salient term ("SBIR", "liquid biopsy", "wastewater") and narrow with `agencies`/`funding_categories`/`status` instead of cramming every concept into this field. A zero-hit result is worth one retry with a shorter keyword before concluding nothing is open.',
|
|
738
|
+
},
|
|
671
739
|
status: {
|
|
672
740
|
type: 'string',
|
|
673
741
|
description: 'forecasted | posted | closed | archived (default posted; use a pipe to combine, e.g. "posted|forecasted")',
|
|
674
742
|
},
|
|
675
743
|
open_only: { type: 'boolean', description: 'true restricts to currently-open or forthcoming opportunities (same as status "posted|forecasted"); omit or false leaves status at its default. Alias for status when a caller only knows open/not-open, not the exact status vocabulary.' },
|
|
676
|
-
agencies: { type: 'string', description: 'Comma-separated agency codes (e.g., "EPA,USDA
|
|
677
|
-
agency: { type: 'string', description: 'Alias for `agencies` — a single agency or department code (e.g. "NIH" or "HHS").' },
|
|
744
|
+
agencies: { type: 'string', description: 'Comma-separated agency codes (e.g., "EPA,USDA" or a bare department like "HHS" or "NIH"). Grants.gov itself indexes opportunities under a FULLY-QUALIFIED sub-agency code ("HHS-NIH11", "HHS-CDC1", ...), and its own filter returns ZERO hits for a bare department code (verified live 2026-09-25, fleet #2450) — this tool detects a bare code automatically and matches it client-side against every hit\'s sub-agency code instead of sending it upstream, so "HHS" still finds NIH/CDC/FDA opportunities. A code you already have in the full "DEPT-SUBAGENCY#" form is sent upstream unchanged.' },
|
|
745
|
+
agency: { type: 'string', description: 'Alias for `agencies` — a single agency or department code (e.g. "NIH" or "HHS"; see `agencies` for how a bare code is matched).' },
|
|
678
746
|
funding_categories: {
|
|
679
747
|
type: 'string',
|
|
680
748
|
description: 'Comma-separated category codes (e.g., "ED" education, "ENV" environment, "ST" science)',
|
|
@@ -701,6 +769,63 @@ const tools: McpToolExport['tools'] = [
|
|
|
701
769
|
required: ['opportunity_id'],
|
|
702
770
|
},
|
|
703
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
|
+
},
|
|
704
829
|
];
|
|
705
830
|
|
|
706
831
|
function reqNum(args: Record<string, unknown>, key: string, example: string): number {
|
|
@@ -712,7 +837,12 @@ function reqNum(args: Record<string, unknown>, key: string, example: string): nu
|
|
|
712
837
|
return n;
|
|
713
838
|
}
|
|
714
839
|
|
|
840
|
+
const MIRROR_TOOLS = new Set(['search_historical_opportunities', 'get_historical_opportunity', 'agency_posting_trend']);
|
|
841
|
+
|
|
715
842
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
843
|
+
if (MIRROR_TOOLS.has(name)) {
|
|
844
|
+
return callMirrorTool(name, args);
|
|
845
|
+
}
|
|
716
846
|
switch (name) {
|
|
717
847
|
case 'search_opportunities':
|
|
718
848
|
return searchOpps(args);
|
|
@@ -738,15 +868,39 @@ async function grantsPost<T>(path: string, body: unknown): Promise<T> {
|
|
|
738
868
|
|
|
739
869
|
async function searchOpps(args: Record<string, unknown>) {
|
|
740
870
|
const status = (args.status as string) ?? (args.open_only === true ? 'posted|forecasted' : 'posted');
|
|
871
|
+
const requestedRows = Math.min(1000, Math.max(1, (args.limit as number) ?? 25));
|
|
872
|
+
const requestedOffset = Math.max(0, (args.offset as number) ?? 0);
|
|
873
|
+
|
|
874
|
+
const rawAgencies = args.agencies ?? args.agency;
|
|
875
|
+
const wantedAgencies = rawAgencies
|
|
876
|
+
? String(rawAgencies).split(',').map((s) => s.trim()).filter(Boolean)
|
|
877
|
+
: [];
|
|
878
|
+
// A code is "bare" when Grants.gov's own filter cannot use it (see
|
|
879
|
+
// agencyMatches' header) — anything without a hyphen, since every real
|
|
880
|
+
// sub-agency code observed live is DEPT-SUBAGENCY#, e.g. "HHS-NIH11".
|
|
881
|
+
const bareAgencies = wantedAgencies.filter((a) => !a.includes('-'));
|
|
882
|
+
const needsClientSideAgencyFilter = bareAgencies.length > 0;
|
|
883
|
+
|
|
741
884
|
const body: Record<string, unknown> = {
|
|
742
|
-
|
|
743
|
-
|
|
885
|
+
// When any wanted code is bare, upstream's own agencies filter cannot
|
|
886
|
+
// resolve it (verified live: {agencies:"HHS"} -> hitCount 0 despite open
|
|
887
|
+
// HHS-NIH* opportunities) — so ask for a full page and filter client-side
|
|
888
|
+
// below instead of asking upstream to do something it cannot do. 1000 is
|
|
889
|
+
// the documented upstream max; sortBy keeps the page deterministic run to
|
|
890
|
+
// run so client-side pagination (offset applied after filtering) is stable.
|
|
891
|
+
rows: needsClientSideAgencyFilter ? 1000 : requestedRows,
|
|
892
|
+
startRecordNum: needsClientSideAgencyFilter ? 0 : requestedOffset,
|
|
744
893
|
sortBy: 'openDate|desc',
|
|
745
894
|
oppStatuses: status,
|
|
746
895
|
};
|
|
747
896
|
if (args.keyword) body.keyword = String(args.keyword);
|
|
748
|
-
|
|
749
|
-
|
|
897
|
+
// Only pass agencies upstream when EVERY wanted code is already fully
|
|
898
|
+
// qualified — mixing a bare and a qualified code and asking upstream to
|
|
899
|
+
// OR them together is not something search2 is known to do correctly, so
|
|
900
|
+
// a mix falls through to client-side filtering for all of them instead.
|
|
901
|
+
if (wantedAgencies.length && !needsClientSideAgencyFilter) {
|
|
902
|
+
body.agencies = wantedAgencies.join(',');
|
|
903
|
+
}
|
|
750
904
|
if (args.funding_categories) body.fundingCategories = String(args.funding_categories);
|
|
751
905
|
if (args.aln) body.cfda = String(args.aln);
|
|
752
906
|
|
|
@@ -763,14 +917,79 @@ async function searchOpps(args: Record<string, unknown>) {
|
|
|
763
917
|
throw new Error(`Grants.gov: ${data.msg ?? 'unknown error'}`);
|
|
764
918
|
}
|
|
765
919
|
|
|
766
|
-
const
|
|
920
|
+
const rawHits = data.data?.oppHits ?? [];
|
|
921
|
+
|
|
922
|
+
if (!needsClientSideAgencyFilter) {
|
|
923
|
+
return {
|
|
924
|
+
total: data.data?.hitCount ?? 0,
|
|
925
|
+
returned: rawHits.length,
|
|
926
|
+
opportunities: rawHits.map(normalizeHit),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const matched = rawHits.filter((h) => agencyMatches(h.agencyCode, wantedAgencies));
|
|
931
|
+
const page = matched.slice(requestedOffset, requestedOffset + requestedRows);
|
|
767
932
|
return {
|
|
768
|
-
|
|
769
|
-
returned
|
|
770
|
-
|
|
933
|
+
// Honest, not fabricated: this counts matches within the page upstream
|
|
934
|
+
// actually returned (up to 1000, the docs' own max), not a full-catalog
|
|
935
|
+
// total — the same distinction `truncated`/`shortfall` exist for
|
|
936
|
+
// elsewhere in the catalog. Upstream gave no way to ask for an exact
|
|
937
|
+
// department-level count directly.
|
|
938
|
+
total: matched.length,
|
|
939
|
+
returned: page.length,
|
|
940
|
+
opportunities: page.map(normalizeHit),
|
|
941
|
+
agency_filter_note:
|
|
942
|
+
`"${wantedAgencies.join(',')}" matched client-side against sub-agency codes in the ` +
|
|
943
|
+
`${rawHits.length} upstream hits for this status/keyword (Grants.gov's own agencies filter ` +
|
|
944
|
+
'needs a fully-qualified code like "HHS-NIH11", not a bare department code) — total reflects ' +
|
|
945
|
+
'matches within that page, not an exact full-catalog count for the department.',
|
|
771
946
|
};
|
|
772
947
|
}
|
|
773
948
|
|
|
949
|
+
/**
|
|
950
|
+
* Does an opportunity's agency code satisfy one of the wanted codes?
|
|
951
|
+
*
|
|
952
|
+
* Grants.gov's real `agencyCode` values are DEPT-SUBAGENCY#, e.g.
|
|
953
|
+
* "HHS-NIH11", "HHS-CDC1", "DOD-AMRAA" — verified live 2026-09-25 (fleet
|
|
954
|
+
* #2450) by searching with no agency filter and reading back real hits'
|
|
955
|
+
* `agencyCode`. A caller (or the router picking arguments for them) very
|
|
956
|
+
* reasonably passes the bare department ("HHS", "NIH", "EPA") the way every
|
|
957
|
+
* OTHER agency-filtered pack in this catalog accepts it — Grants.gov's own
|
|
958
|
+
* `agencies` filter param does not accept that shape and returns hitCount 0
|
|
959
|
+
* rather than erroring, which is indistinguishable from "nothing is open"
|
|
960
|
+
* unless you already know to suspect the filter itself.
|
|
961
|
+
*
|
|
962
|
+
* Matches on: exact equality; the wanted code as a `-`-delimited segment of
|
|
963
|
+
* the hit's code (so "HHS" matches segment "HHS" of "HHS-NIH11", and "NIH"
|
|
964
|
+
* matches a segment that STARTS WITH "NIH" — "NIH11" — since the numeric
|
|
965
|
+
* suffix varies by sub-agency and is not itself part of any recognizable
|
|
966
|
+
* department code, so requiring segment equality would only ever match
|
|
967
|
+
* "NIH" against nothing); or the hit code starting with `wanted-` (handles a
|
|
968
|
+
* caller who already supplies the department-subagency prefix, e.g.
|
|
969
|
+
* "HHS-NIH", without the trailing number).
|
|
970
|
+
*
|
|
971
|
+
* The segment-PREFIX form (`seg.startsWith(w)`, for the numeric-suffix case
|
|
972
|
+
* above) only fires for a wanted code of 3+ characters — real department/
|
|
973
|
+
* sub-agency abbreviations run 2-4 letters, so at 2 characters segment-prefix
|
|
974
|
+
* matching starts producing false positives (a 2-letter code as a prefix of
|
|
975
|
+
* an unrelated 4-6 letter segment, e.g. wanted "NI" would otherwise match
|
|
976
|
+
* USDA's "NIFA" segment). Exact segment/whole-code equality still applies at
|
|
977
|
+
* any length, so a real 2-letter code ("ED", department of Education) still
|
|
978
|
+
* matches an upstream code that uses it as a whole segment.
|
|
979
|
+
*/
|
|
980
|
+
function agencyMatches(hitCode: string | undefined, wanted: string[]): boolean {
|
|
981
|
+
if (!hitCode) return false;
|
|
982
|
+
const upper = hitCode.trim().toUpperCase();
|
|
983
|
+
if (!upper) return false;
|
|
984
|
+
const segments = upper.split('-');
|
|
985
|
+
return wanted.some((raw) => {
|
|
986
|
+
const w = raw.trim().toUpperCase();
|
|
987
|
+
if (!w) return false;
|
|
988
|
+
if (upper === w || upper.startsWith(`${w}-`)) return true;
|
|
989
|
+
return segments.some((seg) => seg === w || (w.length >= 3 && seg.startsWith(w)));
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
774
993
|
async function getOpp(opportunityId: number) {
|
|
775
994
|
const data = await grantsPost<{
|
|
776
995
|
errorcode?: number;
|
|
@@ -882,4 +1101,234 @@ function normalizeDetail(d: OpportunityDetail) {
|
|
|
882
1101
|
};
|
|
883
1102
|
}
|
|
884
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
|
+
|
|
885
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-grants-gov', version: '0.1.4' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|