@pipeworx/mcp-grants-gov 0.1.4 → 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 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 1686+ live data sources.
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
 
@@ -73,7 +73,7 @@ directly, instead of just this one's:
73
73
  }
74
74
  ```
75
75
 
76
- Both URLs reach the same gateway and the same 1686+ data sources. The
76
+ Both URLs reach the same gateway and the same 1879+ data sources. The
77
77
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
78
78
  reaches all of them from either one.
79
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-grants-gov",
3
- "version": "0.1.4",
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-ed594888eaeec8bf4a0e21bd489348e332b301028709ac493d6709e0557847d1",
30
- "sourceCommit": "658bedc4e66c6eab7e17c569f81012a2ee312624"
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.4",
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
  *
@@ -738,7 +1002,8 @@ const tools: McpToolExport['tools'] = [
738
1002
  },
739
1003
  status: {
740
1004
  type: 'string',
741
- description: 'forecasted | posted | closed | archived (default posted; use a pipe to combine, e.g. "posted|forecasted")',
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.',
742
1007
  },
743
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.' },
744
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.' },
@@ -786,7 +1051,8 @@ const tools: McpToolExport['tools'] = [
786
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.' },
787
1052
  status: {
788
1053
  type: 'string',
789
- description: 'posted | closed | archived | forecasted. Omit to search every status, unlike the live tool (whose default is "posted").',
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").',
790
1056
  },
791
1057
  opportunity_category: { type: 'string', description: 'Single-letter Grants.gov category code: D discretionary, M mandatory, C continuation, E earmark, O other.' },
792
1058
  post_date_from: { type: 'string', description: 'Only opportunities posted on/after this date, YYYY-MM-DD.' },
@@ -837,6 +1103,7 @@ function reqNum(args: Record<string, unknown>, key: string, example: string): nu
837
1103
  return n;
838
1104
  }
839
1105
 
1106
+ const GRANTS_STATUSES = ['posted', 'closed', 'archived', 'forecasted'];
840
1107
  const MIRROR_TOOLS = new Set(['search_historical_opportunities', 'get_historical_opportunity', 'agency_posting_trend']);
841
1108
 
842
1109
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -867,7 +1134,21 @@ async function grantsPost<T>(path: string, body: unknown): Promise<T> {
867
1134
  }
868
1135
 
869
1136
  async function searchOpps(args: Record<string, unknown>) {
870
- const status = (args.status as string) ?? (args.open_only === true ? 'posted|forecasted' : 'posted');
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');
871
1152
  const requestedRows = Math.min(1000, Math.max(1, (args.limit as number) ?? 25));
872
1153
  const requestedOffset = Math.max(0, (args.offset as number) ?? 0);
873
1154
 
@@ -920,10 +1201,16 @@ async function searchOpps(args: Record<string, unknown>) {
920
1201
  const rawHits = data.data?.oppHits ?? [];
921
1202
 
922
1203
  if (!needsClientSideAgencyFilter) {
1204
+ const total = data.data?.hitCount ?? 0;
923
1205
  return {
924
- total: data.data?.hitCount ?? 0,
1206
+ total,
925
1207
  returned: rawHits.length,
926
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
+ : {}),
927
1214
  };
928
1215
  }
929
1216
 
@@ -1002,7 +1289,17 @@ async function getOpp(opportunityId: number) {
1002
1289
  }
1003
1290
  const d = data.data;
1004
1291
  if (!d) throw new Error(`Grants.gov: no data for opportunity ${opportunityId}`);
1005
-
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
+ }
1006
1303
  return normalizeDetail(d);
1007
1304
  }
1008
1305
 
@@ -1198,11 +1495,22 @@ function shapeSearchRow(r: MirrorSearchRow) {
1198
1495
  async function searchHistorical(cfg: DbConfig, args: Record<string, unknown>) {
1199
1496
  const limit = Math.min(500, Math.max(1, Number(args.limit) || 25));
1200
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);
1201
1509
  const rows = (await sbRpc(cfg, 'grants_gov_search', {
1202
- p_agency_code: agencyArg(args) ?? null,
1510
+ p_agency_code: agencyIn ?? null,
1203
1511
  p_cfda: (args.cfda ?? args.aln) ? String(args.cfda ?? args.aln) : null,
1204
1512
  p_keyword: args.keyword ? String(args.keyword) : null,
1205
- p_status: args.status ? String(args.status).toLowerCase() : null,
1513
+ p_status: status,
1206
1514
  p_opportunity_category: args.opportunity_category ? String(args.opportunity_category) : null,
1207
1515
  p_post_date_from: args.post_date_from ? String(args.post_date_from) : null,
1208
1516
  p_post_date_to: args.post_date_to ? String(args.post_date_to) : null,
@@ -1210,12 +1518,21 @@ async function searchHistorical(cfg: DbConfig, args: Record<string, unknown>) {
1210
1518
  p_offset: offset,
1211
1519
  })) as MirrorSearchRow[];
1212
1520
 
1521
+ const agencyLooksLikeName = agencyIn !== undefined && (/\s/.test(agencyIn) || agencyIn.length > 16);
1213
1522
  return {
1214
1523
  total: rows.length ? rows[0].total_count : 0,
1215
1524
  returned: rows.length,
1216
1525
  offset,
1217
1526
  opportunities: rows.map(shapeSearchRow),
1218
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
+ : {}),
1219
1536
  };
1220
1537
  }
1221
1538
 
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.4' },
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 result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
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 {