@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 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 1681+ live data sources.
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 1681+ data sources. The
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.2",
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-be925a6e6350a16b702fd0c26a84dc08a92ebe3529b5908765bf64aeb6a04074",
30
- "sourceCommit": "d82b03e5d3bf01fd0dfba214ff31dabe330f2ab6"
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.2",
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
- throw err;
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: { type: 'string', description: 'Full-text query' },
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,NIH"). A parent department code (e.g. "HHS") also matches its sub-agencies (NIH, CDC, FDA, ...) — Grants.gov indexes opportunities under the posting sub-agency, not just the department.' },
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
- rows: Math.min(1000, Math.max(1, (args.limit as number) ?? 25)),
743
- startRecordNum: (args.offset as number) ?? 0,
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
- const agencies = args.agencies ?? args.agency;
749
- if (agencies) body.agencies = String(agencies);
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 hits = data.data?.oppHits ?? [];
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
- total: data.data?.hitCount ?? 0,
769
- returned: hits.length,
770
- opportunities: hits.map(normalizeHit),
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.2' },
12
+ { name: '@pipeworx/mcp-grants-gov', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15