@pipeworx/mcp-grants-gov 0.1.3 → 0.1.5
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 +644 -6
- package/src/server.ts +5 -2
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 1879+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
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 1879+ 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.5",
|
|
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-f50f98d6224512fd191b91e450f154acc44d5a59caa5058a0e9f4c36e0854fbe",
|
|
30
|
+
"sourceCommit": "445bacc5596a845c12d6945fbfcdf330ef60dcc4"
|
|
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.5",
|
|
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
|
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
|
|
|
295
295
|
// internal-host-class.ts; no-op for every third-party upstream, which is why
|
|
296
296
|
// this touches 481 packs' error text and changes none of it.
|
|
297
297
|
return markInternalOrigin(
|
|
298
|
-
`${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
|
|
298
|
+
`${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
|
|
299
299
|
res.url,
|
|
300
300
|
res.status,
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Facts about a FAILED response that live in its headers, not its body, appended
|
|
306
|
+
* so the gateway's classifier (which only ever sees this string) can tell a limit
|
|
307
|
+
* from a wall from a refusal (fleet #3477). Both are standard, both are absent on
|
|
308
|
+
* almost every response, and neither is invented: `Retry-After` is the server
|
|
309
|
+
* saying "come back later" (the one thing that makes a 403 a throttle), and
|
|
310
|
+
* Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
|
|
311
|
+
*/
|
|
312
|
+
function responseHints(res: Response): string {
|
|
313
|
+
const hints: string[] = [];
|
|
314
|
+
const retryAfter = res.headers.get('retry-after');
|
|
315
|
+
if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
|
|
316
|
+
if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
|
|
317
|
+
return hints.length ? ` (${hints.join('; ')})` : '';
|
|
318
|
+
}
|
|
319
|
+
|
|
304
320
|
/**
|
|
305
321
|
* Just the upstream's own explanation — no name, no status.
|
|
306
322
|
*
|
|
@@ -670,6 +686,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
670
686
|
function collapse(s: string): string {
|
|
671
687
|
return s.replace(/\s+/g, ' ').trim();
|
|
672
688
|
}
|
|
689
|
+
|
|
690
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
691
|
+
//
|
|
692
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
693
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
694
|
+
// and once as the object graph (which for an array of small objects runs
|
|
695
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
696
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
697
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
698
|
+
// the 503/1102 rate at all.
|
|
699
|
+
//
|
|
700
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
701
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
702
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
703
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
704
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
705
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
706
|
+
//
|
|
707
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
708
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
709
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
710
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
711
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
712
|
+
|
|
713
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
714
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
715
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
716
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
717
|
+
|
|
718
|
+
interface JsonArrayPrefix<T> {
|
|
719
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
720
|
+
items: T[];
|
|
721
|
+
/** True when the upstream array had more elements than we read. */
|
|
722
|
+
truncated: boolean;
|
|
723
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
724
|
+
bytesRead: number;
|
|
725
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
726
|
+
declaredBytes: number | null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
731
|
+
* stream without ever materialising the whole body.
|
|
732
|
+
*
|
|
733
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
734
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
735
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
736
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
737
|
+
*
|
|
738
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
739
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
740
|
+
*
|
|
741
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
742
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
743
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
744
|
+
* more useful to a caller than an error.
|
|
745
|
+
*/
|
|
746
|
+
async function readJsonArrayPrefix<T>(
|
|
747
|
+
res: Response,
|
|
748
|
+
name: string,
|
|
749
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
750
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
751
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
752
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
753
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
754
|
+
|
|
755
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
756
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
757
|
+
if (!res.body) {
|
|
758
|
+
const all = await parseJson<T[]>(res, name);
|
|
759
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
760
|
+
return {
|
|
761
|
+
items: all.slice(0, opts.maxItems),
|
|
762
|
+
truncated: all.length > opts.maxItems,
|
|
763
|
+
bytesRead: declaredBytes ?? 0,
|
|
764
|
+
declaredBytes,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const reader = res.body.getReader();
|
|
769
|
+
const decoder = new TextDecoder();
|
|
770
|
+
const items: T[] = [];
|
|
771
|
+
|
|
772
|
+
let buf = '';
|
|
773
|
+
let scan = 0;
|
|
774
|
+
let bytesRead = 0;
|
|
775
|
+
let started = false; // consumed the opening '['
|
|
776
|
+
let arrayEnded = false;
|
|
777
|
+
let depth = 0;
|
|
778
|
+
let inStr = false;
|
|
779
|
+
let esc = false;
|
|
780
|
+
let elemStart = -1;
|
|
781
|
+
let truncated = false;
|
|
782
|
+
|
|
783
|
+
const push = (text: string) => {
|
|
784
|
+
const t = text.trim();
|
|
785
|
+
if (!t) return;
|
|
786
|
+
items.push(JSON.parse(t) as T);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
791
|
+
const { value, done } = await reader.read();
|
|
792
|
+
if (done) break;
|
|
793
|
+
bytesRead += value.byteLength;
|
|
794
|
+
buf += decoder.decode(value, { stream: true });
|
|
795
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
796
|
+
|
|
797
|
+
for (; scan < buf.length; scan++) {
|
|
798
|
+
const ch = buf[scan];
|
|
799
|
+
|
|
800
|
+
if (inStr) {
|
|
801
|
+
if (esc) { esc = false; continue; }
|
|
802
|
+
if (ch === '\\') { esc = true; continue; }
|
|
803
|
+
if (ch === '"') inStr = false;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!started) {
|
|
808
|
+
if (ch === '[') { started = true; continue; }
|
|
809
|
+
if (ch.trim() === '') continue;
|
|
810
|
+
throw new Error(
|
|
811
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
812
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
817
|
+
|
|
818
|
+
if (ch === '{' || ch === '[') {
|
|
819
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
820
|
+
depth++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (ch === '}' || ch === ']') {
|
|
825
|
+
if (depth > 0) {
|
|
826
|
+
depth--;
|
|
827
|
+
if (depth === 0 && elemStart >= 0) {
|
|
828
|
+
push(buf.slice(elemStart, scan + 1));
|
|
829
|
+
elemStart = -1;
|
|
830
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
835
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
836
|
+
arrayEnded = true;
|
|
837
|
+
break outer;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (ch === ',' && depth === 0) {
|
|
841
|
+
if (elemStart >= 0) {
|
|
842
|
+
push(buf.slice(elemStart, scan));
|
|
843
|
+
elemStart = -1;
|
|
844
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A bare scalar element (number / true / false / null).
|
|
850
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
854
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
855
|
+
if (elemStart >= 0) {
|
|
856
|
+
buf = buf.slice(elemStart);
|
|
857
|
+
scan -= elemStart;
|
|
858
|
+
elemStart = 0;
|
|
859
|
+
} else {
|
|
860
|
+
buf = buf.slice(scan);
|
|
861
|
+
scan = 0;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
866
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
867
|
+
await reader.cancel().catch(() => {});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
875
|
+
*
|
|
876
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
877
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
878
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
879
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
880
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
881
|
+
* single chunk is read.
|
|
882
|
+
*/
|
|
883
|
+
async function parseJsonBounded<T>(
|
|
884
|
+
res: Response,
|
|
885
|
+
name: string,
|
|
886
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
887
|
+
): Promise<T> {
|
|
888
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
889
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
890
|
+
|
|
891
|
+
const tooBig = (n: number) =>
|
|
892
|
+
new Error(
|
|
893
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
894
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
895
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
896
|
+
'a specific id, or a lower limit) and retry.',
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
if (declared !== null && declared > maxBytes) {
|
|
900
|
+
await res.body?.cancel().catch(() => {});
|
|
901
|
+
throw tooBig(declared);
|
|
902
|
+
}
|
|
903
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
904
|
+
|
|
905
|
+
const reader = res.body.getReader();
|
|
906
|
+
const decoder = new TextDecoder();
|
|
907
|
+
let raw = '';
|
|
908
|
+
let bytesRead = 0;
|
|
909
|
+
try {
|
|
910
|
+
for (;;) {
|
|
911
|
+
const { value, done } = await reader.read();
|
|
912
|
+
if (done) break;
|
|
913
|
+
bytesRead += value.byteLength;
|
|
914
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
915
|
+
raw += decoder.decode(value, { stream: true });
|
|
916
|
+
}
|
|
917
|
+
raw += decoder.decode();
|
|
918
|
+
} finally {
|
|
919
|
+
await reader.cancel().catch(() => {});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!raw.trim()) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
925
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
return JSON.parse(raw) as T;
|
|
930
|
+
} catch {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
933
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
673
937
|
/**
|
|
674
938
|
* Grants.gov MCP — open federal grant opportunities (free, no auth)
|
|
675
939
|
*
|
|
@@ -677,9 +941,38 @@ function collapse(s: string): string {
|
|
|
677
941
|
* the federal-wide clearinghouse for currently-open and forecasted opportunities.
|
|
678
942
|
*
|
|
679
943
|
* API: https://www.grants.gov/web/grants/s2s/grantor/schemas/grants-gov-search2.html
|
|
680
|
-
* Tools:
|
|
944
|
+
* Tools (live proxy, currently-open coverage only):
|
|
681
945
|
* - search_opportunities: filter by keyword, status, agency, funding category, dates
|
|
682
946
|
* - get_opportunity: single full record with attachments and synopsis text
|
|
947
|
+
*
|
|
948
|
+
* Tools (local mirror, fleet #2512 — closed/archived/forecasted history the
|
|
949
|
+
* live API structurally cannot serve):
|
|
950
|
+
* - search_historical_opportunities: agency/CFDA/keyword/status (incl. closed
|
|
951
|
+
* and forecasted)/date-range search over the FULL extract, back to 2004.
|
|
952
|
+
* - get_historical_opportunity: one record from the mirror, any status.
|
|
953
|
+
* - agency_posting_trend: an agency's opportunity count / funding
|
|
954
|
+
* by fiscal year, derived from post_date.
|
|
955
|
+
*
|
|
956
|
+
* WHY A MIRROR FOR HISTORY: search2 (BASE_URL below) is Grants.gov's live
|
|
957
|
+
* index and only usefully answers "what is open or about to open right
|
|
958
|
+
* now" — there is no live endpoint for "what did this agency post five
|
|
959
|
+
* fiscal years ago" or "what was forecasted but never opened". Per Bruce's
|
|
960
|
+
* 2026-09-23 ruling ("build a copy when live doesn't serve"), the historical
|
|
961
|
+
* tools below read migration 226's grants_gov_opportunities, loaded from
|
|
962
|
+
* Grants.gov's own daily full-catalog XML export
|
|
963
|
+
* (grants.gov/xml-extract -> prod-grants-gov-chatbot.s3.amazonaws.com,
|
|
964
|
+
* verified live 2026-09-30: 83,497 records, no auth) by
|
|
965
|
+
* scripts/grants-gov-upsert.sh via .github/workflows/grants-gov-refresh.yml.
|
|
966
|
+
* `data_as_of` is on every mirror response (the last successful refresh,
|
|
967
|
+
* read from grants_gov_ingest_runs via the grants_gov_coverage() RPC).
|
|
968
|
+
* The live tools (search_opportunities/get_opportunity) are UNCHANGED —
|
|
969
|
+
* they still proxy search2 for the "what's open right now" case, which the
|
|
970
|
+
* mirror (refreshed once daily) is never quite fresh enough for.
|
|
971
|
+
*
|
|
972
|
+
* NO NEW PERSONAL DATA: the mirror carries GrantorContactName/Text, an
|
|
973
|
+
* agency's published point-of-contact for the opportunity — the same class
|
|
974
|
+
* of disclosure get_opportunity's live GrantorContact* fields already
|
|
975
|
+
* surface; see the migration file for the full reasoning.
|
|
683
976
|
*/
|
|
684
977
|
|
|
685
978
|
|
|
@@ -709,7 +1002,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
709
1002
|
},
|
|
710
1003
|
status: {
|
|
711
1004
|
type: 'string',
|
|
712
|
-
|
|
1005
|
+
enum: ['posted', 'closed', 'archived', 'forecasted'],
|
|
1006
|
+
description: 'posted (open for applications now) | forecasted (announced, not yet posted) | closed | archived. Default posted. There is no "open" or "active" status: use "posted". For posted plus forecasted together pass open_only: true.',
|
|
713
1007
|
},
|
|
714
1008
|
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.' },
|
|
715
1009
|
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.' },
|
|
@@ -740,6 +1034,64 @@ const tools: McpToolExport['tools'] = [
|
|
|
740
1034
|
required: ['opportunity_id'],
|
|
741
1035
|
},
|
|
742
1036
|
},
|
|
1037
|
+
{
|
|
1038
|
+
name: 'search_historical_opportunities',
|
|
1039
|
+
description:
|
|
1040
|
+
'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".',
|
|
1041
|
+
inputSchema: {
|
|
1042
|
+
type: 'object',
|
|
1043
|
+
properties: {
|
|
1044
|
+
agency_code: {
|
|
1045
|
+
type: 'string',
|
|
1046
|
+
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.',
|
|
1047
|
+
},
|
|
1048
|
+
agency: { type: 'string', description: 'Alias for agency_code.' },
|
|
1049
|
+
cfda: { type: 'string', description: 'Assistance Listing Number / CFDA number, exact match, e.g. "93.351". Alias: aln.' },
|
|
1050
|
+
aln: { type: 'string', description: 'Alias for cfda.' },
|
|
1051
|
+
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.' },
|
|
1052
|
+
status: {
|
|
1053
|
+
type: 'string',
|
|
1054
|
+
enum: ['posted', 'closed', 'archived', 'forecasted'],
|
|
1055
|
+
description: 'posted (open now) | closed | archived | forecasted (not yet posted). One value. There is no "open" or "active" status: use "posted" for opportunities open for applications. Omit to search every status, unlike the live tool (whose default is "posted").',
|
|
1056
|
+
},
|
|
1057
|
+
opportunity_category: { type: 'string', description: 'Single-letter Grants.gov category code: D discretionary, M mandatory, C continuation, E earmark, O other.' },
|
|
1058
|
+
post_date_from: { type: 'string', description: 'Only opportunities posted on/after this date, YYYY-MM-DD.' },
|
|
1059
|
+
post_date_to: { type: 'string', description: 'Only opportunities posted on/before this date, YYYY-MM-DD.' },
|
|
1060
|
+
limit: { type: 'number', description: 'Rows to return, 1-500 (default 25).' },
|
|
1061
|
+
offset: { type: 'number', description: '0-based start record, for paging.' },
|
|
1062
|
+
},
|
|
1063
|
+
required: [],
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
name: 'get_historical_opportunity',
|
|
1068
|
+
description:
|
|
1069
|
+
'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`.',
|
|
1070
|
+
inputSchema: {
|
|
1071
|
+
type: 'object',
|
|
1072
|
+
properties: {
|
|
1073
|
+
opportunity_id: {
|
|
1074
|
+
type: 'number',
|
|
1075
|
+
description: 'Numeric opportunity ID (returned by search_historical_opportunities or search_opportunities as "id").',
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
required: ['opportunity_id'],
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
name: 'agency_posting_trend',
|
|
1083
|
+
description:
|
|
1084
|
+
'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.',
|
|
1085
|
+
inputSchema: {
|
|
1086
|
+
type: 'object',
|
|
1087
|
+
properties: {
|
|
1088
|
+
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.' },
|
|
1089
|
+
agency: { type: 'string', description: 'Alias for agency_code.' },
|
|
1090
|
+
years: { type: 'number', description: 'Number of most-recent fiscal years to return, 1-40 (default 10).' },
|
|
1091
|
+
},
|
|
1092
|
+
required: [],
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
743
1095
|
];
|
|
744
1096
|
|
|
745
1097
|
function reqNum(args: Record<string, unknown>, key: string, example: string): number {
|
|
@@ -751,7 +1103,13 @@ function reqNum(args: Record<string, unknown>, key: string, example: string): nu
|
|
|
751
1103
|
return n;
|
|
752
1104
|
}
|
|
753
1105
|
|
|
1106
|
+
const GRANTS_STATUSES = ['posted', 'closed', 'archived', 'forecasted'];
|
|
1107
|
+
const MIRROR_TOOLS = new Set(['search_historical_opportunities', 'get_historical_opportunity', 'agency_posting_trend']);
|
|
1108
|
+
|
|
754
1109
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1110
|
+
if (MIRROR_TOOLS.has(name)) {
|
|
1111
|
+
return callMirrorTool(name, args);
|
|
1112
|
+
}
|
|
755
1113
|
switch (name) {
|
|
756
1114
|
case 'search_opportunities':
|
|
757
1115
|
return searchOpps(args);
|
|
@@ -776,7 +1134,21 @@ async function grantsPost<T>(path: string, body: unknown): Promise<T> {
|
|
|
776
1134
|
}
|
|
777
1135
|
|
|
778
1136
|
async function searchOpps(args: Record<string, unknown>) {
|
|
779
|
-
|
|
1137
|
+
// fleet #3471: an off-vocabulary status ("open", "active") was sent upstream,
|
|
1138
|
+
// matched nothing, and came back as total 0 with empty_reason no_match -- a
|
|
1139
|
+
// confident claim the catalog held nothing. Refuse it before any query.
|
|
1140
|
+
// A pipe-joined list of legal values is still accepted (the upstream syntax).
|
|
1141
|
+
const statusArg = args.status == null || String(args.status).trim() === '' ? null : String(args.status).trim().toLowerCase();
|
|
1142
|
+
if (statusArg !== null) {
|
|
1143
|
+
const parts = statusArg.split('|').map((x) => x.trim());
|
|
1144
|
+
if (parts.some((x) => !GRANTS_STATUSES.includes(x))) {
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
`user_error: status "${String(args.status)}" is not a Grants.gov status. Use exactly one of: ${GRANTS_STATUSES.join(', ')}. ` +
|
|
1147
|
+
'"posted" is what is open for applications now; "forecasted" is announced but not yet posted; use open_only: true for posted plus forecasted.',
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const status = statusArg ?? (args.open_only === true ? 'posted|forecasted' : 'posted');
|
|
780
1152
|
const requestedRows = Math.min(1000, Math.max(1, (args.limit as number) ?? 25));
|
|
781
1153
|
const requestedOffset = Math.max(0, (args.offset as number) ?? 0);
|
|
782
1154
|
|
|
@@ -829,10 +1201,16 @@ async function searchOpps(args: Record<string, unknown>) {
|
|
|
829
1201
|
const rawHits = data.data?.oppHits ?? [];
|
|
830
1202
|
|
|
831
1203
|
if (!needsClientSideAgencyFilter) {
|
|
1204
|
+
const total = data.data?.hitCount ?? 0;
|
|
832
1205
|
return {
|
|
833
|
-
total
|
|
1206
|
+
total,
|
|
834
1207
|
returned: rawHits.length,
|
|
835
1208
|
opportunities: rawHits.map(normalizeHit),
|
|
1209
|
+
// fleet #3471 audit: an unrecognised funding_categories code matches nothing
|
|
1210
|
+
// upstream and read as "no opportunities" (BOGUS -> total 0, no_match).
|
|
1211
|
+
...(total === 0 && args.funding_categories
|
|
1212
|
+
? { hint: `funding_categories "${String(args.funding_categories)}" was sent upstream as given; a code Grants.gov does not recognise matches nothing, which looks identical to no opportunities. Codes are short upper-case ones such as AG, ED, ENV, HL, HU, ST, T; retry without funding_categories to confirm.` }
|
|
1213
|
+
: {}),
|
|
836
1214
|
};
|
|
837
1215
|
}
|
|
838
1216
|
|
|
@@ -911,7 +1289,17 @@ async function getOpp(opportunityId: number) {
|
|
|
911
1289
|
}
|
|
912
1290
|
const d = data.data;
|
|
913
1291
|
if (!d) throw new Error(`Grants.gov: no data for opportunity ${opportunityId}`);
|
|
914
|
-
|
|
1292
|
+
// fleet #3471 audit: an id the live API does not hold (closed/archived, or never
|
|
1293
|
+
// existed) came back as a record of 25 nulls. That is a "not found" wearing the
|
|
1294
|
+
// shape of a found record, so say so.
|
|
1295
|
+
if (d.id == null && d.opportunityNumber == null && d.opportunityTitle == null) {
|
|
1296
|
+
return {
|
|
1297
|
+
found: false,
|
|
1298
|
+
reason: 'not_found_in_live_api',
|
|
1299
|
+
opportunity_id: opportunityId,
|
|
1300
|
+
hint: 'The live Grants.gov API does not hold this opportunity (it may be closed or archived, or the id is wrong). Try get_historical_opportunity, which resolves an opportunity in any status.',
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
915
1303
|
return normalizeDetail(d);
|
|
916
1304
|
}
|
|
917
1305
|
|
|
@@ -1010,4 +1398,254 @@ function normalizeDetail(d: OpportunityDetail) {
|
|
|
1010
1398
|
};
|
|
1011
1399
|
}
|
|
1012
1400
|
|
|
1401
|
+
/* ----------------------------------------------------- mirror (local DB) */
|
|
1402
|
+
|
|
1403
|
+
interface DbConfig {
|
|
1404
|
+
url: string;
|
|
1405
|
+
key: string;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function sbRpc(cfg: DbConfig, fn: string, body: Record<string, unknown>): Promise<unknown[]> {
|
|
1409
|
+
const url = `${cfg.url}/rest/v1/rpc/${fn}`;
|
|
1410
|
+
const res = await pwFetch(url, {
|
|
1411
|
+
method: 'POST',
|
|
1412
|
+
headers: {
|
|
1413
|
+
apikey: cfg.key,
|
|
1414
|
+
Authorization: `Bearer ${cfg.key}`,
|
|
1415
|
+
'Content-Type': 'application/json',
|
|
1416
|
+
Prefer: 'params=single-object',
|
|
1417
|
+
},
|
|
1418
|
+
body: JSON.stringify(body),
|
|
1419
|
+
});
|
|
1420
|
+
if (!res.ok) throw await httpError(res, 'Grants.gov historical data');
|
|
1421
|
+
const parsed = await res.json();
|
|
1422
|
+
if (!Array.isArray(parsed)) {
|
|
1423
|
+
throw new Error('Grants.gov historical data: RPC did not return a row array — treating this as a failure.');
|
|
1424
|
+
}
|
|
1425
|
+
return parsed;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
let cachedDataAsOf: { value: string | null; coverage: Record<string, unknown> | null } | undefined;
|
|
1429
|
+
|
|
1430
|
+
/** The last successful refresh + coverage stats, read once per Worker instance and reused across calls. */
|
|
1431
|
+
async function mirrorCoverage(cfg: DbConfig): Promise<{ value: string | null; coverage: Record<string, unknown> | null }> {
|
|
1432
|
+
if (cachedDataAsOf !== undefined) return cachedDataAsOf;
|
|
1433
|
+
try {
|
|
1434
|
+
const rows = (await sbRpc(cfg, 'grants_gov_coverage', {})) as Record<string, unknown>[];
|
|
1435
|
+
const row = rows[0] ?? null;
|
|
1436
|
+
cachedDataAsOf = { value: (row?.data_as_of as string | undefined) ?? null, coverage: row };
|
|
1437
|
+
} catch {
|
|
1438
|
+
cachedDataAsOf = { value: null, coverage: null }; // freshness is metadata, never the reason a call fails
|
|
1439
|
+
}
|
|
1440
|
+
return cachedDataAsOf;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function withDataAsOf<T extends Record<string, unknown>>(result: T, asOf: string | null): T & { data_as_of: string | null } {
|
|
1444
|
+
return {
|
|
1445
|
+
...result,
|
|
1446
|
+
data_as_of: asOf,
|
|
1447
|
+
...(asOf
|
|
1448
|
+
? {}
|
|
1449
|
+
: {
|
|
1450
|
+
coverage_status:
|
|
1451
|
+
'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.',
|
|
1452
|
+
}),
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function agencyArg(args: Record<string, unknown>): string | undefined {
|
|
1457
|
+
const raw = (args.agency_code ?? args.agency) as string | undefined;
|
|
1458
|
+
return raw ? String(raw).trim() : undefined;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
interface MirrorSearchRow {
|
|
1462
|
+
opportunity_id: number;
|
|
1463
|
+
opportunity_title: string | null;
|
|
1464
|
+
opportunity_number: string | null;
|
|
1465
|
+
agency_code: string | null;
|
|
1466
|
+
agency_name: string | null;
|
|
1467
|
+
status: string | null;
|
|
1468
|
+
post_date: string | null;
|
|
1469
|
+
close_date: string | null;
|
|
1470
|
+
fiscal_year: number | null;
|
|
1471
|
+
cfda_numbers: string[] | null;
|
|
1472
|
+
award_ceiling: number | null;
|
|
1473
|
+
estimated_total_program_funding: number | null;
|
|
1474
|
+
total_count: number;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function shapeSearchRow(r: MirrorSearchRow) {
|
|
1478
|
+
return {
|
|
1479
|
+
id: r.opportunity_id,
|
|
1480
|
+
title: r.opportunity_title,
|
|
1481
|
+
number: r.opportunity_number,
|
|
1482
|
+
agency_code: r.agency_code,
|
|
1483
|
+
agency: r.agency_name,
|
|
1484
|
+
status: r.status,
|
|
1485
|
+
post_date: r.post_date,
|
|
1486
|
+
close_date: r.close_date,
|
|
1487
|
+
fiscal_year: r.fiscal_year,
|
|
1488
|
+
assistance_listing_numbers: r.cfda_numbers ?? [],
|
|
1489
|
+
award_ceiling: r.award_ceiling,
|
|
1490
|
+
estimated_total_program_funding: r.estimated_total_program_funding,
|
|
1491
|
+
grants_gov_url: `https://www.grants.gov/search-results-detail/${r.opportunity_id}`,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function searchHistorical(cfg: DbConfig, args: Record<string, unknown>) {
|
|
1496
|
+
const limit = Math.min(500, Math.max(1, Number(args.limit) || 25));
|
|
1497
|
+
const offset = Math.max(0, Number(args.offset) || 0);
|
|
1498
|
+
const HISTORICAL_STATUSES = GRANTS_STATUSES;
|
|
1499
|
+
const status = args.status == null || String(args.status).trim() === '' ? null : String(args.status).trim().toLowerCase();
|
|
1500
|
+
// fleet #3404: an off-vocabulary status ("open", "active") matched nothing in
|
|
1501
|
+
// the RPC and came back as a clean total:0. Refuse it, naming the legal four.
|
|
1502
|
+
if (status !== null && !HISTORICAL_STATUSES.includes(status)) {
|
|
1503
|
+
throw new Error(
|
|
1504
|
+
`user_error: status "${String(args.status)}" is not a Grants.gov status. Use exactly one of: ${HISTORICAL_STATUSES.join(', ')}. ` +
|
|
1505
|
+
'"posted" is what is open for applications now; "forecasted" is announced but not yet posted; omit status to search all four.',
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
const agencyIn = agencyArg(args);
|
|
1509
|
+
const rows = (await sbRpc(cfg, 'grants_gov_search', {
|
|
1510
|
+
p_agency_code: agencyIn ?? null,
|
|
1511
|
+
p_cfda: (args.cfda ?? args.aln) ? String(args.cfda ?? args.aln) : null,
|
|
1512
|
+
p_keyword: args.keyword ? String(args.keyword) : null,
|
|
1513
|
+
p_status: status,
|
|
1514
|
+
p_opportunity_category: args.opportunity_category ? String(args.opportunity_category) : null,
|
|
1515
|
+
p_post_date_from: args.post_date_from ? String(args.post_date_from) : null,
|
|
1516
|
+
p_post_date_to: args.post_date_to ? String(args.post_date_to) : null,
|
|
1517
|
+
p_limit: limit,
|
|
1518
|
+
p_offset: offset,
|
|
1519
|
+
})) as MirrorSearchRow[];
|
|
1520
|
+
|
|
1521
|
+
const agencyLooksLikeName = agencyIn !== undefined && (/\s/.test(agencyIn) || agencyIn.length > 16);
|
|
1522
|
+
return {
|
|
1523
|
+
total: rows.length ? rows[0].total_count : 0,
|
|
1524
|
+
returned: rows.length,
|
|
1525
|
+
offset,
|
|
1526
|
+
opportunities: rows.map(shapeSearchRow),
|
|
1527
|
+
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).',
|
|
1528
|
+
...(rows.length === 0 && offset === 0
|
|
1529
|
+
? {
|
|
1530
|
+
empty_reason: agencyLooksLikeName ? 'agency_not_a_code' : 'no_match',
|
|
1531
|
+
hint: agencyLooksLikeName
|
|
1532
|
+
? `agency_code takes an agency CODE such as "NIH", "HHS-NIH11" or "DOS", not a name ("${agencyIn}"). Pass the code, or put the name words in \`keyword\`.`
|
|
1533
|
+
: 'No opportunity in the catalog matches every filter given. Loosen one: drop status, use a bare department code (e.g. "HHS") or a shorter keyword.',
|
|
1534
|
+
}
|
|
1535
|
+
: {}),
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async function getHistorical(cfg: DbConfig, opportunityId: number) {
|
|
1540
|
+
const rows = (await sbRpc(cfg, 'grants_gov_get', { p_opportunity_id: opportunityId })) as Record<string, unknown>[];
|
|
1541
|
+
const row = rows[0];
|
|
1542
|
+
if (!row) {
|
|
1543
|
+
return {
|
|
1544
|
+
found: false,
|
|
1545
|
+
reason: `No opportunity with id ${opportunityId} in Grants.gov's historical dataset.`,
|
|
1546
|
+
hint: 'Check the id with search_historical_opportunities, or the id may belong to an opportunity type not yet loaded.',
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const status =
|
|
1550
|
+
row.record_type === 'forecast'
|
|
1551
|
+
? 'forecasted'
|
|
1552
|
+
: !row.close_date || (row.close_date as string) >= new Date().toISOString().slice(0, 10)
|
|
1553
|
+
? 'posted'
|
|
1554
|
+
: !row.archive_date || (row.archive_date as string) >= new Date().toISOString().slice(0, 10)
|
|
1555
|
+
? 'closed'
|
|
1556
|
+
: 'archived';
|
|
1557
|
+
return {
|
|
1558
|
+
found: true,
|
|
1559
|
+
id: row.opportunity_id,
|
|
1560
|
+
record_type: row.record_type,
|
|
1561
|
+
status,
|
|
1562
|
+
title: row.opportunity_title,
|
|
1563
|
+
number: row.opportunity_number,
|
|
1564
|
+
category: row.opportunity_category,
|
|
1565
|
+
funding_instrument_type: row.funding_instrument_type,
|
|
1566
|
+
funding_activity_categories: row.category_of_funding_activity ?? [],
|
|
1567
|
+
assistance_listing_numbers: row.cfda_numbers ?? [],
|
|
1568
|
+
eligible_applicant_codes: row.eligible_applicant_codes ?? [],
|
|
1569
|
+
eligibility_note: row.additional_information_on_eligibility,
|
|
1570
|
+
agency_code: row.agency_code,
|
|
1571
|
+
agency: row.agency_name,
|
|
1572
|
+
post_date: row.post_date,
|
|
1573
|
+
close_date: row.close_date,
|
|
1574
|
+
archive_date: row.archive_date,
|
|
1575
|
+
last_updated_date: row.last_updated_date,
|
|
1576
|
+
award_ceiling: row.award_ceiling,
|
|
1577
|
+
award_floor: row.award_floor,
|
|
1578
|
+
estimated_total_program_funding: row.estimated_total_program_funding,
|
|
1579
|
+
expected_number_of_awards: row.expected_number_of_awards,
|
|
1580
|
+
description: row.description,
|
|
1581
|
+
cost_sharing_required: row.cost_sharing_or_matching_requirement,
|
|
1582
|
+
contact_name: row.grantor_contact_name,
|
|
1583
|
+
contact_email: row.grantor_contact_email,
|
|
1584
|
+
contact_phone: row.grantor_contact_phone_number,
|
|
1585
|
+
fiscal_year: row.fiscal_year,
|
|
1586
|
+
estimated_synopsis_post_date: row.estimated_synopsis_post_date,
|
|
1587
|
+
estimated_synopsis_close_date: row.estimated_synopsis_close_date,
|
|
1588
|
+
estimated_award_date: row.estimated_award_date,
|
|
1589
|
+
grants_gov_url: `https://www.grants.gov/search-results-detail/${row.opportunity_id}`,
|
|
1590
|
+
source: 'Grants.gov\'s own full-catalog extract, refreshed daily.',
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
interface TrendRow {
|
|
1595
|
+
fiscal_year: number;
|
|
1596
|
+
opportunities_posted: number;
|
|
1597
|
+
total_estimated_funding: number | null;
|
|
1598
|
+
avg_award_ceiling: number | null;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function agencyTrend(cfg: DbConfig, args: Record<string, unknown>) {
|
|
1602
|
+
const agency = agencyArg(args);
|
|
1603
|
+
if (!agency) {
|
|
1604
|
+
throw new Error('agency_posting_trend needs an agency code. Pass agency_code (or its alias agency), e.g. "EPA" or "HHS-NIH11".');
|
|
1605
|
+
}
|
|
1606
|
+
const years = Math.min(40, Math.max(1, Number(args.years) || 10));
|
|
1607
|
+
const rows = (await sbRpc(cfg, 'grants_gov_agency_trend', { p_agency_code: agency, p_years: years })) as TrendRow[];
|
|
1608
|
+
return {
|
|
1609
|
+
agency_code: agency,
|
|
1610
|
+
years_returned: rows.length,
|
|
1611
|
+
trend: rows.map((r) => ({
|
|
1612
|
+
fiscal_year: r.fiscal_year,
|
|
1613
|
+
opportunities_posted: r.opportunities_posted,
|
|
1614
|
+
total_estimated_funding: r.total_estimated_funding,
|
|
1615
|
+
avg_award_ceiling: r.avg_award_ceiling,
|
|
1616
|
+
})),
|
|
1617
|
+
source: 'Grants.gov\'s own full-catalog extract, refreshed daily; fiscal year derived from each opportunity\'s post_date (federal FY = Oct-Sep).',
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function callMirrorTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1622
|
+
const url = (args._supabaseUrl as string | undefined)?.trim();
|
|
1623
|
+
const key = (args._supabaseKey as string | undefined)?.trim();
|
|
1624
|
+
if (!url || !key) {
|
|
1625
|
+
throw new Error(
|
|
1626
|
+
'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.',
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
const cfg: DbConfig = { url, key };
|
|
1630
|
+
const asOf = await mirrorCoverage(cfg);
|
|
1631
|
+
|
|
1632
|
+
let result: unknown;
|
|
1633
|
+
switch (name) {
|
|
1634
|
+
case 'search_historical_opportunities':
|
|
1635
|
+
result = await searchHistorical(cfg, args);
|
|
1636
|
+
break;
|
|
1637
|
+
case 'get_historical_opportunity':
|
|
1638
|
+
result = await getHistorical(cfg, reqNum(args, 'opportunity_id', '363992 (numeric ID)'));
|
|
1639
|
+
break;
|
|
1640
|
+
case 'agency_posting_trend':
|
|
1641
|
+
result = await agencyTrend(cfg, args);
|
|
1642
|
+
break;
|
|
1643
|
+
default:
|
|
1644
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1645
|
+
}
|
|
1646
|
+
return result && typeof result === 'object' && !Array.isArray(result)
|
|
1647
|
+
? withDataAsOf(result as Record<string, unknown>, asOf.value)
|
|
1648
|
+
: result;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1013
1651
|
export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-grants-gov', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-grants-gov', version: '0.1.5' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|