@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 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 1681+ 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 1681+ 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.2",
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-be925a6e6350a16b702fd0c26a84dc08a92ebe3529b5908765bf64aeb6a04074",
30
- "sourceCommit": "d82b03e5d3bf01fd0dfba214ff31dabe330f2ab6"
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.2",
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
- 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
 
@@ -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: { type: 'string', description: 'Full-text query' },
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,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").' },
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
- rows: Math.min(1000, Math.max(1, (args.limit as number) ?? 25)),
743
- startRecordNum: (args.offset as number) ?? 0,
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
- const agencies = args.agencies ?? args.agency;
749
- if (agencies) body.agencies = String(agencies);
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 hits = data.data?.oppHits ?? [];
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
- total: data.data?.hitCount ?? 0,
769
- returned: hits.length,
770
- opportunities: hits.map(normalizeHit),
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.2' },
12
+ { name: '@pipeworx/mcp-grants-gov', version: '0.1.4' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15