@pipeworx/mcp-grants-gov 0.1.2 → 0.1.3
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 +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +140 -12
- package/src/server.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 1683+ live data sources.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
@@ -57,7 +57,7 @@ directly, instead of just this one's:
|
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
Both URLs reach the same gateway and the same
|
|
60
|
+
Both URLs reach the same gateway and the same 1683+ data sources. The
|
|
61
61
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
62
62
|
reaches all of them from either one.
|
|
63
63
|
|
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.3",
|
|
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-1846b32008e4c510b8dde408336adacab39956e0b19cc5caaf9c36b82d53b018",
|
|
30
|
+
"sourceCommit": "1064560fd9dcd0d6fc2606298c2ba244b3a1fbeb"
|
|
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.3",
|
|
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
|
|
|
@@ -667,14 +702,18 @@ const tools: McpToolExport['tools'] = [
|
|
|
667
702
|
inputSchema: {
|
|
668
703
|
type: 'object',
|
|
669
704
|
properties: {
|
|
670
|
-
keyword: {
|
|
705
|
+
keyword: {
|
|
706
|
+
type: 'string',
|
|
707
|
+
description:
|
|
708
|
+
'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.',
|
|
709
|
+
},
|
|
671
710
|
status: {
|
|
672
711
|
type: 'string',
|
|
673
712
|
description: 'forecasted | posted | closed | archived (default posted; use a pipe to combine, e.g. "posted|forecasted")',
|
|
674
713
|
},
|
|
675
714
|
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").' },
|
|
715
|
+
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.' },
|
|
716
|
+
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
717
|
funding_categories: {
|
|
679
718
|
type: 'string',
|
|
680
719
|
description: 'Comma-separated category codes (e.g., "ED" education, "ENV" environment, "ST" science)',
|
|
@@ -738,15 +777,39 @@ async function grantsPost<T>(path: string, body: unknown): Promise<T> {
|
|
|
738
777
|
|
|
739
778
|
async function searchOpps(args: Record<string, unknown>) {
|
|
740
779
|
const status = (args.status as string) ?? (args.open_only === true ? 'posted|forecasted' : 'posted');
|
|
780
|
+
const requestedRows = Math.min(1000, Math.max(1, (args.limit as number) ?? 25));
|
|
781
|
+
const requestedOffset = Math.max(0, (args.offset as number) ?? 0);
|
|
782
|
+
|
|
783
|
+
const rawAgencies = args.agencies ?? args.agency;
|
|
784
|
+
const wantedAgencies = rawAgencies
|
|
785
|
+
? String(rawAgencies).split(',').map((s) => s.trim()).filter(Boolean)
|
|
786
|
+
: [];
|
|
787
|
+
// A code is "bare" when Grants.gov's own filter cannot use it (see
|
|
788
|
+
// agencyMatches' header) — anything without a hyphen, since every real
|
|
789
|
+
// sub-agency code observed live is DEPT-SUBAGENCY#, e.g. "HHS-NIH11".
|
|
790
|
+
const bareAgencies = wantedAgencies.filter((a) => !a.includes('-'));
|
|
791
|
+
const needsClientSideAgencyFilter = bareAgencies.length > 0;
|
|
792
|
+
|
|
741
793
|
const body: Record<string, unknown> = {
|
|
742
|
-
|
|
743
|
-
|
|
794
|
+
// When any wanted code is bare, upstream's own agencies filter cannot
|
|
795
|
+
// resolve it (verified live: {agencies:"HHS"} -> hitCount 0 despite open
|
|
796
|
+
// HHS-NIH* opportunities) — so ask for a full page and filter client-side
|
|
797
|
+
// below instead of asking upstream to do something it cannot do. 1000 is
|
|
798
|
+
// the documented upstream max; sortBy keeps the page deterministic run to
|
|
799
|
+
// run so client-side pagination (offset applied after filtering) is stable.
|
|
800
|
+
rows: needsClientSideAgencyFilter ? 1000 : requestedRows,
|
|
801
|
+
startRecordNum: needsClientSideAgencyFilter ? 0 : requestedOffset,
|
|
744
802
|
sortBy: 'openDate|desc',
|
|
745
803
|
oppStatuses: status,
|
|
746
804
|
};
|
|
747
805
|
if (args.keyword) body.keyword = String(args.keyword);
|
|
748
|
-
|
|
749
|
-
|
|
806
|
+
// Only pass agencies upstream when EVERY wanted code is already fully
|
|
807
|
+
// qualified — mixing a bare and a qualified code and asking upstream to
|
|
808
|
+
// OR them together is not something search2 is known to do correctly, so
|
|
809
|
+
// a mix falls through to client-side filtering for all of them instead.
|
|
810
|
+
if (wantedAgencies.length && !needsClientSideAgencyFilter) {
|
|
811
|
+
body.agencies = wantedAgencies.join(',');
|
|
812
|
+
}
|
|
750
813
|
if (args.funding_categories) body.fundingCategories = String(args.funding_categories);
|
|
751
814
|
if (args.aln) body.cfda = String(args.aln);
|
|
752
815
|
|
|
@@ -763,14 +826,79 @@ async function searchOpps(args: Record<string, unknown>) {
|
|
|
763
826
|
throw new Error(`Grants.gov: ${data.msg ?? 'unknown error'}`);
|
|
764
827
|
}
|
|
765
828
|
|
|
766
|
-
const
|
|
829
|
+
const rawHits = data.data?.oppHits ?? [];
|
|
830
|
+
|
|
831
|
+
if (!needsClientSideAgencyFilter) {
|
|
832
|
+
return {
|
|
833
|
+
total: data.data?.hitCount ?? 0,
|
|
834
|
+
returned: rawHits.length,
|
|
835
|
+
opportunities: rawHits.map(normalizeHit),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const matched = rawHits.filter((h) => agencyMatches(h.agencyCode, wantedAgencies));
|
|
840
|
+
const page = matched.slice(requestedOffset, requestedOffset + requestedRows);
|
|
767
841
|
return {
|
|
768
|
-
|
|
769
|
-
returned
|
|
770
|
-
|
|
842
|
+
// Honest, not fabricated: this counts matches within the page upstream
|
|
843
|
+
// actually returned (up to 1000, the docs' own max), not a full-catalog
|
|
844
|
+
// total — the same distinction `truncated`/`shortfall` exist for
|
|
845
|
+
// elsewhere in the catalog. Upstream gave no way to ask for an exact
|
|
846
|
+
// department-level count directly.
|
|
847
|
+
total: matched.length,
|
|
848
|
+
returned: page.length,
|
|
849
|
+
opportunities: page.map(normalizeHit),
|
|
850
|
+
agency_filter_note:
|
|
851
|
+
`"${wantedAgencies.join(',')}" matched client-side against sub-agency codes in the ` +
|
|
852
|
+
`${rawHits.length} upstream hits for this status/keyword (Grants.gov's own agencies filter ` +
|
|
853
|
+
'needs a fully-qualified code like "HHS-NIH11", not a bare department code) — total reflects ' +
|
|
854
|
+
'matches within that page, not an exact full-catalog count for the department.',
|
|
771
855
|
};
|
|
772
856
|
}
|
|
773
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Does an opportunity's agency code satisfy one of the wanted codes?
|
|
860
|
+
*
|
|
861
|
+
* Grants.gov's real `agencyCode` values are DEPT-SUBAGENCY#, e.g.
|
|
862
|
+
* "HHS-NIH11", "HHS-CDC1", "DOD-AMRAA" — verified live 2026-09-25 (fleet
|
|
863
|
+
* #2450) by searching with no agency filter and reading back real hits'
|
|
864
|
+
* `agencyCode`. A caller (or the router picking arguments for them) very
|
|
865
|
+
* reasonably passes the bare department ("HHS", "NIH", "EPA") the way every
|
|
866
|
+
* OTHER agency-filtered pack in this catalog accepts it — Grants.gov's own
|
|
867
|
+
* `agencies` filter param does not accept that shape and returns hitCount 0
|
|
868
|
+
* rather than erroring, which is indistinguishable from "nothing is open"
|
|
869
|
+
* unless you already know to suspect the filter itself.
|
|
870
|
+
*
|
|
871
|
+
* Matches on: exact equality; the wanted code as a `-`-delimited segment of
|
|
872
|
+
* the hit's code (so "HHS" matches segment "HHS" of "HHS-NIH11", and "NIH"
|
|
873
|
+
* matches a segment that STARTS WITH "NIH" — "NIH11" — since the numeric
|
|
874
|
+
* suffix varies by sub-agency and is not itself part of any recognizable
|
|
875
|
+
* department code, so requiring segment equality would only ever match
|
|
876
|
+
* "NIH" against nothing); or the hit code starting with `wanted-` (handles a
|
|
877
|
+
* caller who already supplies the department-subagency prefix, e.g.
|
|
878
|
+
* "HHS-NIH", without the trailing number).
|
|
879
|
+
*
|
|
880
|
+
* The segment-PREFIX form (`seg.startsWith(w)`, for the numeric-suffix case
|
|
881
|
+
* above) only fires for a wanted code of 3+ characters — real department/
|
|
882
|
+
* sub-agency abbreviations run 2-4 letters, so at 2 characters segment-prefix
|
|
883
|
+
* matching starts producing false positives (a 2-letter code as a prefix of
|
|
884
|
+
* an unrelated 4-6 letter segment, e.g. wanted "NI" would otherwise match
|
|
885
|
+
* USDA's "NIFA" segment). Exact segment/whole-code equality still applies at
|
|
886
|
+
* any length, so a real 2-letter code ("ED", department of Education) still
|
|
887
|
+
* matches an upstream code that uses it as a whole segment.
|
|
888
|
+
*/
|
|
889
|
+
function agencyMatches(hitCode: string | undefined, wanted: string[]): boolean {
|
|
890
|
+
if (!hitCode) return false;
|
|
891
|
+
const upper = hitCode.trim().toUpperCase();
|
|
892
|
+
if (!upper) return false;
|
|
893
|
+
const segments = upper.split('-');
|
|
894
|
+
return wanted.some((raw) => {
|
|
895
|
+
const w = raw.trim().toUpperCase();
|
|
896
|
+
if (!w) return false;
|
|
897
|
+
if (upper === w || upper.startsWith(`${w}-`)) return true;
|
|
898
|
+
return segments.some((seg) => seg === w || (w.length >= 3 && seg.startsWith(w)));
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
774
902
|
async function getOpp(opportunityId: number) {
|
|
775
903
|
const data = await grantsPost<{
|
|
776
904
|
errorcode?: number;
|
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.3' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|