@cliwant/mcp-sam-gov 1.9.0 → 1.11.0

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.
Files changed (78) hide show
  1. package/README.ja.md +5 -5
  2. package/README.ko.md +5 -5
  3. package/README.md +19 -7
  4. package/dist/arcgis-feature.d.ts +68 -0
  5. package/dist/arcgis-feature.d.ts.map +1 -0
  6. package/dist/arcgis-feature.js +206 -0
  7. package/dist/arcgis-feature.js.map +1 -0
  8. package/dist/arcgis-hub.d.ts +90 -0
  9. package/dist/arcgis-hub.d.ts.map +1 -0
  10. package/dist/arcgis-hub.js +210 -0
  11. package/dist/arcgis-hub.js.map +1 -0
  12. package/dist/bonfire.d.ts +69 -0
  13. package/dist/bonfire.d.ts.map +1 -0
  14. package/dist/bonfire.js +212 -0
  15. package/dist/bonfire.js.map +1 -0
  16. package/dist/cbp-border.d.ts +9 -5
  17. package/dist/cbp-border.d.ts.map +1 -1
  18. package/dist/cbp-border.js +31 -11
  19. package/dist/cbp-border.js.map +1 -1
  20. package/dist/gao.d.ts.map +1 -1
  21. package/dist/gao.js +25 -0
  22. package/dist/gao.js.map +1 -1
  23. package/dist/grants.js +1 -1
  24. package/dist/grants.js.map +1 -1
  25. package/dist/gsa-perdiem.d.ts +16 -2
  26. package/dist/gsa-perdiem.d.ts.map +1 -1
  27. package/dist/gsa-perdiem.js +25 -4
  28. package/dist/gsa-perdiem.js.map +1 -1
  29. package/dist/integrity.d.ts.map +1 -1
  30. package/dist/integrity.js +18 -3
  31. package/dist/integrity.js.map +1 -1
  32. package/dist/lda.d.ts.map +1 -1
  33. package/dist/lda.js +17 -4
  34. package/dist/lda.js.map +1 -1
  35. package/dist/nhtsa.d.ts +9 -5
  36. package/dist/nhtsa.d.ts.map +1 -1
  37. package/dist/nhtsa.js +90 -26
  38. package/dist/nhtsa.js.map +1 -1
  39. package/dist/nist-controls.d.ts +3 -1
  40. package/dist/nist-controls.d.ts.map +1 -1
  41. package/dist/nist-controls.js +34 -12
  42. package/dist/nist-controls.js.map +1 -1
  43. package/dist/ofac.d.ts.map +1 -1
  44. package/dist/ofac.js +10 -1
  45. package/dist/ofac.js.map +1 -1
  46. package/dist/opengov.d.ts +96 -0
  47. package/dist/opengov.d.ts.map +1 -0
  48. package/dist/opengov.js +244 -0
  49. package/dist/opengov.js.map +1 -0
  50. package/dist/pricing.d.ts +1 -1
  51. package/dist/sam-gov/client.d.ts.map +1 -1
  52. package/dist/sam-gov/client.js +13 -4
  53. package/dist/sam-gov/client.js.map +1 -1
  54. package/dist/server.d.ts +4 -0
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +199 -8
  57. package/dist/server.js.map +1 -1
  58. package/dist/socrata.d.ts +27 -16
  59. package/dist/socrata.d.ts.map +1 -1
  60. package/dist/socrata.js +149 -18
  61. package/dist/socrata.js.map +1 -1
  62. package/package.json +11 -3
  63. package/src/arcgis-feature.ts +232 -0
  64. package/src/arcgis-hub.ts +270 -0
  65. package/src/bonfire.ts +249 -0
  66. package/src/cbp-border.ts +30 -9
  67. package/src/gao.ts +27 -0
  68. package/src/grants.ts +1 -1
  69. package/src/gsa-perdiem.ts +28 -4
  70. package/src/integrity.ts +18 -3
  71. package/src/lda.ts +21 -5
  72. package/src/nhtsa.ts +92 -27
  73. package/src/nist-controls.ts +58 -9
  74. package/src/ofac.ts +12 -1
  75. package/src/opengov.ts +309 -0
  76. package/src/sam-gov/client.ts +13 -4
  77. package/src/server.ts +213 -8
  78. package/src/socrata.ts +153 -18
package/src/bonfire.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * bonfire.ts — Bonfire (Euna Solutions) per-organization open-solicitation RSS,
3
+ * a keyless-first SLED bid source (loop — SLED bid campaign, 2026-07-19).
4
+ *
5
+ * WHAT IT ADDS: Bonfire hosts the open-bid portals of thousands of US state/local
6
+ * governments; each org exposes a KEYLESS RSS 2.0 feed of its currently-open
7
+ * opportunities at `https://{org}.bonfirehub.com/opportunities/rss` (live-verified
8
+ * on Dallas/Harris County/Utah/Bernalillo/…). One of the two highest-reach keyless
9
+ * SLED bid feeds (with OpenGov Procurement).
10
+ *
11
+ * ★ NO keyless directory API: Bonfire's authoritative org list
12
+ * (`GET common-production-api-global.bonfirehub.com/v1.0/organizations/external`)
13
+ * is AUTH-GATED (a free vendor-account token) — OUT OF BOUNDS (we never sign in).
14
+ * So this ships a CURATED, live-verified SEED directory (187 US orgs; §BONFIRE_
15
+ * ORGS) as `bonfire_list_organizations`, and documents the keyless RSS-probe
16
+ * refresh method (no catch-all: `{slug}.bonfirehub.com/opportunities/rss` returns
17
+ * 200 <rss> for a real org, a connection failure for a non-provisioned slug). The
18
+ * seed is a PARTIAL directory (Euna markets up to ~900 US orgs) — disclosed.
19
+ *
20
+ * The module REUSES `getText` (shared XML/RSS fetch, redirect:"error") /
21
+ * `driftError` / `str`·`num` / `withMeta`·`buildMeta`, mirroring fpds/gao. KEYLESS.
22
+ *
23
+ * ★ SSRF: org is charclass-validated (`^[a-z0-9-]{1,64}$` — no dots), the URL is
24
+ * built on the FIXED `.bonfirehub.com` suffix, and a post-construction assertion
25
+ * requires `hostname === {org}.bonfirehub.com` (over https) BEFORE the fetch;
26
+ * `redirect:"error"`.
27
+ *
28
+ * ★ HONESTY PILLARS:
29
+ * P1: the RSS is the COMPLETE current open-opportunity set for the org (no server
30
+ * pagination), so totalAvailable = the parsed item count (the true total, NOT a
31
+ * page length); client-side limit/offset page over it.
32
+ * P2: getText THROWS on 429 / 5xx / 404 / timeout — NEVER a fake empty. An empty
33
+ * channel (0 items) ⇒ honest empty (the org has no open opportunities now).
34
+ * P3: every field via `str` (null-never-empty); dates surfaced verbatim.
35
+ * P4: a 200 body that is not RSS (no `<rss`/`<channel`) ⇒ schema_drift (never
36
+ * parsed as an empty set).
37
+ */
38
+
39
+ import { ToolErrorCarrier } from "./errors.js";
40
+ import { getText, driftError } from "./datasource.js";
41
+ import { num, str } from "./coerce.js";
42
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
43
+
44
+ export { num };
45
+
46
+ // ─── Fixed suffix (SSRF core) + org grammar ───────────────────────
47
+ const BONFIRE_SUFFIX = ".bonfirehub.com";
48
+ export const BONFIRE_ORG_RE = /^[a-z0-9-]{1,64}$/;
49
+ const bonfireLabel = (org: string) => `bonfire:${org}/opportunities/rss`;
50
+ const BONFIRE_SOURCE = (org: string) =>
51
+ `${org}.bonfirehub.com via Bonfire (Euna) open-opportunities RSS (keyless)`;
52
+
53
+ const BONFIRE_SEED_NOTE =
54
+ "This directory is a CURATED, live-verified SEED (Bonfire has NO keyless org-list API; the authoritative list is auth-gated and out of bounds). Euna markets up to ~900 US orgs, so the seed is partial — probe `{slug}.bonfirehub.com/opportunities/rss` (200 <rss> = real org) to extend. Feed a result's `org` to bonfire_search_opportunities.";
55
+
56
+ // ─── The curated 187-org US seed directory (live-verified 2026-07-19) ──
57
+ // "slug|Entity|ST" — the slug is the RSS subdomain. Non-US (.ca / cayman / etc.)
58
+ // deliberately excluded.
59
+ const BONFIRE_SEED_RAW: readonly string[] = [
60
+ "littlerock|City of Little Rock|AR",
61
+ "buckeyeaz|City of Buckeye|AZ", "goodyearaz|City of Goodyear|AZ", "peoriaaz|City of Peoria|AZ", "scottsdaleaz|City of Scottsdale|AZ", "yumaaz|City of Yuma|AZ", "pinalcountyaz|Pinal County|AZ", "susd|Scottsdale USD|AZ", "tollesonuhsd|Tolleson Union HSD|AZ",
62
+ "cityofirvine|City of Irvine|CA", "oaklandca|City of Oakland|CA", "alamedacounty|Alameda County|CA", "solanocounty|Solano County|CA", "ventura|County of Ventura|CA", "yolocounty|Yolo County|CA", "mtc|Metropolitan Transportation Commission|CA", "azusa|Azusa USD|CA", "stocktonusd|Stockton USD|CA", "weta|SF Bay Area Water Emergency Transportation Authority|CA", "acwd|Alameda County Water District|CA", "wrd|Water Replenishment District of Southern CA|CA", "actransit|AC Transit|CA", "marintransit|Marin Transit|CA", "mst|Monterey-Salinas Transit|CA", "omnitrans|Omnitrans|CA", "smctd|San Mateo County Transit District|CA", "sonomamarintrain|Sonoma-Marin Area Rail Transit|CA", "ggbhtd|Golden Gate Bridge Highway & Transportation District|CA",
63
+ "bouldercounty|Boulder County|CO",
64
+ "crcog|Capitol Region Council of Governments|CT", "easternct|Eastern Connecticut State University|CT",
65
+ "dfm|DE OMB - Division of Facility Management|DE", "gss|DE OMB - Government Support Services|DE",
66
+ "daviefl|Town of Davie|FL", "ocoee|City of Ocoee|FL", "broward|Broward County|FL", "hillsboroughcounty|Hillsborough County|FL", "marionfl|Marion County|FL", "monroecounty-fl|Monroe County|FL", "pascocountyfl|Pasco County|FL", "famu|Florida A&M University|FL", "fau|Florida Atlantic University|FL", "fgcu|Florida Gulf Coast University|FL", "floridapoly|Florida Polytechnic University|FL", "ucf|University of Central Florida|FL", "tampabaywater|Tampa Bay Water|FL", "gohart|Hillsborough Transit Authority|FL", "psta|Pinellas Suncoast Transit Authority|FL",
67
+ "brookhavenga|City of Brookhaven|GA", "sandysprings|City of Sandy Springs|GA", "chathamcountyga|Chatham County|GA", "columbiacountyga|Columbia County|GA", "gwinnett|Gwinnett County Public Schools|GA",
68
+ "cityofnampa|City of Nampa|ID", "adacounty|Ada County|ID", "bannockcounty|Bannock County|ID",
69
+ "cookcountyil|Cook County|IL", "thecha|Chicago Housing Authority|IL", "cps|Chicago Public Schools|IL", "u-46|School District U-46|IL", "chicagoparkdistrict|Chicago Park District|IL", "mwrd|Metro Water Reclamation District of Greater Chicago|IL", "transitchicago|Chicago Transit Authority|IL", "metra|Metra|IL",
70
+ "indygo|Indianapolis Public Transportation Corp|IN",
71
+ "olatheks|City of Olathe|KS", "wichita|City of Wichita|KS",
72
+ "covingtonky|City of Covington|KY", "louisvilleky|City of Louisville|KY", "owensboro|City of Owensboro|KY", "lexingtonky|Lexington-Fayette|KY", "kyhousing|Kentucky Housing Corporation|KY", "tarc|Transit Authority of River City|KY",
73
+ "umass|University of Massachusetts|MA",
74
+ "harfordcountymd|Harford County|MD", "habc|Housing Authority of Baltimore City|MD", "hcpss|Howard County Public School System|MD", "menv|Maryland Environmental Service|MD", "mdcourts|Maryland Judiciary|MD",
75
+ "maine|State of Maine|ME",
76
+ "detroit|City of Detroit|MI",
77
+ "ramseycountymn|Ramsey County|MN", "sourcewell|Sourcewell|MN",
78
+ "jeffersoncitymo|Jefferson City|MO", "stlouiscountymo|St. Louis County|MO", "stlcc|St. Louis Community College|MO",
79
+ "apexnc|Town of Apex|NC", "charlottenc|City of Charlotte|NC", "wake|Wake County|NC", "ncat|NC A&T State University|NC", "ncsu|NC State University|NC",
80
+ "rutgers|Rutgers University|NJ",
81
+ "cabq|City of Albuquerque|NM", "mckinleycounty|McKinley County|NM",
82
+ "clarkcountynv|Clark County|NV", "ccsd|Clark County School District|NV",
83
+ "suffolkcountyny|Suffolk County|NY", "stonybrook|Stony Brook University|NY", "healthsolutions|Public Health Solutions|NY", "centro|Central NY Regional Transportation Authority|NY", "nfta|Niagara Frontier Transportation Authority|NY", "panynj|Port Authority of New York & New Jersey|NY",
84
+ "akronohio|City of Akron|OH", "cincinnati-oh|City of Cincinnati|OH", "columbus|City of Columbus|OH", "equalisgroup|Equalis Group|OH",
85
+ "pdx|Portland State University|OR",
86
+ "pennbid|PennBid|PA", "alleghenycounty|Allegheny County|PA",
87
+ "charlestoncounty|Charleston County|SC", "tridenttech|Trident Technical College|SC",
88
+ "apsu|Austin Peay State University|TN",
89
+ "dfwairport|DFW International Airport|TX", "amarillo|City of Amarillo|TX", "arlingtontx|City of Arlington|TX", "brownsvilletx|City of Brownsville|TX", "burlesontx|City of Burleson|TX", "cityoflewisville|City of Lewisville|TX", "dallascityhall|City of Dallas|TX", "fortworthtexas|City of Fort Worth|TX", "friscotexas|City of Frisco|TX", "leandertx|City of Leander|TX", "mckinneytexas|City of McKinney|TX", "midlandtexas|City of Midland|TX", "roundrocktexas|City of Round Rock|TX", "sanantonio|City of San Antonio|TX", "schertz|City of Schertz|TX", "southlake|City of Southlake|TX", "templetx|City of Temple|TX", "waco-texas|City of Waco|TX", "mansfield|Mansfield Council of Governments|TX", "brazoriacounty|Brazoria County|TX", "dentoncounty|Denton County|TX", "galvestoncountytx|Galveston County|TX", "harriscountytx|Harris County|TX", "johnsoncountytx|Johnson County|TX", "lubbock|Lubbock County|TX", "parkercountytx|Parker County|TX", "smithcounty|Smith County|TX", "wilco|Williamson County|TX", "hccs|Houston Community College|TX", "rice-edu|Rice University|TX", "tccd|Tarrant County College District|TX", "utdallas|UT Dallas|TX", "utexas|UT Austin|TX", "utrgv|UT Rio Grande Valley|TX", "uttyler|UT Tyler|TX", "uthscsa|UT Health San Antonio|TX", "saha|Opportunity Home San Antonio|TX", "universityhealth|University Health (hospital district)|TX", "allenisd|Allen ISD|TX", "austinisd|Austin ISD|TX", "comalisd|Comal ISD|TX", "dallasisd|Dallas ISD|TX", "fortbendisd|Fort Bend ISD|TX", "kleinisd|Klein ISD|TX", "laredoisd|Laredo ISD|TX", "magnoliaisd|Magnolia ISD|TX", "mesquiteisd|Mesquite ISD|TX", "tomballisd|Tomball ISD|TX", "twc-texas-gov|Texas Workforce Commission|TX", "txdot|Texas Department of Transportation|TX", "dart|Dallas Area Rapid Transit|TX", "ridemetro|Harris County METRO|TX",
90
+ "ccog|The Cooperative Council of Governments|US", "omniapartners|OMNIA Partners|US", "utah|U3P / Utah Public Procurement Place|UT",
91
+ "fairfaxcounty|Fairfax County|VA", "cnu|Christopher Newport University|VA", "gmu|George Mason University|VA", "nsu|Norfolk State University|VA", "fcps|Fairfax County Public Schools|VA",
92
+ "cityofvancouver|City of Vancouver WA|WA", "federalwaywa|City of Federal Way|WA", "clarkcountywa|Clark County WA|WA", "kingcounty|King County|WA", "portolympia|Port of Olympia|WA", "lwsd|Lake Washington School District|WA", "tacoma|Tacoma Public Schools|WA",
93
+ "cityofmilwaukee|City of Milwaukee|WI", "westalliswi|City of West Allis|WI", "racinecounty|Racine County|WI", "waukeshacounty|Waukesha County|WI", "cvtc|Chippewa Valley Technical College|WI", "mmsd|Milwaukee Metropolitan Sewerage District|WI",
94
+ "marshall|Marshall University|WV",
95
+ ];
96
+
97
+ export type BonfireOrg = { org: string; name: string; state: string };
98
+ export const BONFIRE_ORGS: readonly BonfireOrg[] = BONFIRE_SEED_RAW.map((r) => {
99
+ const [org, name, state] = r.split("|");
100
+ return { org: org ?? "", name: name ?? "", state: state ?? "" };
101
+ });
102
+
103
+ // ─── XML helpers (bespoke regex parser, mirrors fpds/gao) ─────────
104
+ function decodeEntities(s: string): string {
105
+ return s
106
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"')
107
+ .replace(/&#0?39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
108
+ }
109
+ function tag(block: string, name: string): string | null {
110
+ const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(block);
111
+ if (!m) return null;
112
+ return decodeEntities((m[1] ?? "").replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim());
113
+ }
114
+
115
+ export type BonfireOpportunity = {
116
+ referenceNumber: string | null;
117
+ name: string | null;
118
+ description: string | null;
119
+ closeDate: string | null;
120
+ link: string | null;
121
+ pubDate: string | null;
122
+ };
123
+
124
+ /** Parse a Bonfire RSS item block → curated opportunity. Title is
125
+ * "Reference #: <ref>. Name: <name>"; description embeds "closes <date>". */
126
+ function mapItem(block: string): BonfireOpportunity {
127
+ const title = tag(block, "title");
128
+ const description = tag(block, "description");
129
+ let referenceNumber: string | null = null;
130
+ let name: string | null = str(title);
131
+ if (title) {
132
+ const m = /Reference #:\s*(.*?)\.\s*Name:\s*([\s\S]*)$/.exec(title);
133
+ if (m) { referenceNumber = str(m[1]); name = str(m[2]); }
134
+ }
135
+ let closeDate: string | null = null;
136
+ if (description) {
137
+ const c = /\bcloses\s+([A-Za-z0-9:,\s]+?(?:AM|PM)[A-Za-z0-9 ]*)$/i.exec(description) || /\bcloses\s+(.+)$/i.exec(description);
138
+ if (c && c[1]) closeDate = str(c[1].trim());
139
+ }
140
+ return { referenceNumber, name, description: str(description), closeDate, link: str(tag(block, "link")), pubDate: str(tag(block, "pubDate")) };
141
+ }
142
+
143
+ // ─── SSRF-guarded fetch ───────────────────────────────────────────
144
+ async function getBonfireRss(org: string): Promise<string> {
145
+ const host = `${org}${BONFIRE_SUFFIX}`;
146
+ const url = `https://${host}/opportunities/rss`;
147
+ const built = new URL(url);
148
+ if (built.hostname !== host || !built.hostname.endsWith(BONFIRE_SUFFIX) || built.protocol !== "https:") {
149
+ throw new ToolErrorCarrier({
150
+ kind: "invalid_input",
151
+ message: `Constructed Bonfire URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
152
+ retryable: false,
153
+ upstreamEndpoint: bonfireLabel(org),
154
+ });
155
+ }
156
+ return getText(url, { label: bonfireLabel(org), redirect: "error" });
157
+ }
158
+
159
+ // ─── Tool 1: bonfire_list_organizations (curated seed directory) ──
160
+ export type BonfireListArgs = { state?: string; query?: string; limit?: number; offset?: number };
161
+
162
+ export async function listOrganizations(args: BonfireListArgs): Promise<MetaBundle> {
163
+ const limit = args.limit ?? 50;
164
+ const offset = args.offset ?? 0;
165
+ const stateFilter = args.state ? args.state.trim().toUpperCase() : null;
166
+ const queryFilter = args.query ? args.query.trim().toLowerCase() : null;
167
+ const filtersApplied: string[] = [];
168
+ if (stateFilter) filtersApplied.push("state");
169
+ if (queryFilter) filtersApplied.push("query");
170
+
171
+ const filtered = BONFIRE_ORGS.filter(
172
+ (o) =>
173
+ (!stateFilter || o.state.toUpperCase() === stateFilter) &&
174
+ (!queryFilter || o.name.toLowerCase().includes(queryFilter)),
175
+ );
176
+ const totalAvailable = filtered.length;
177
+ const page = filtered.slice(offset, offset + limit);
178
+ const returned = page.length;
179
+ const hasMore = offset + returned < totalAvailable;
180
+
181
+ return withMeta(
182
+ { organizations: page },
183
+ {
184
+ source: "Bonfire (Euna) curated seed directory (keyless)",
185
+ keylessMode: true,
186
+ returned,
187
+ totalAvailable,
188
+ filtersApplied,
189
+ filtersDropped: [],
190
+ fieldsUnavailable: [],
191
+ pagination: { offset, limit, hasMore, nextOffset: hasMore ? offset + returned : null },
192
+ notes: [BONFIRE_SEED_NOTE],
193
+ } satisfies Partial<ResponseMeta>,
194
+ );
195
+ }
196
+
197
+ // ─── Tool 2: bonfire_search_opportunities (per-org RSS) ───────────
198
+ export type BonfireSearchArgs = { org: string; limit?: number; offset?: number };
199
+
200
+ export async function searchOpportunities(args: BonfireSearchArgs): Promise<MetaBundle> {
201
+ const limit = args.limit ?? 50;
202
+ const offset = args.offset ?? 0;
203
+ const org = args.org ?? "";
204
+
205
+ if (!BONFIRE_ORG_RE.test(org)) {
206
+ throw new ToolErrorCarrier({
207
+ kind: "invalid_input",
208
+ message: `Invalid Bonfire org ${JSON.stringify(org)} — expected a lowercase-alnum/hyphen subdomain slug (from bonfire_list_organizations, e.g. 'harriscountytx', 'u-46').`,
209
+ retryable: false,
210
+ upstreamEndpoint: bonfireLabel(org),
211
+ });
212
+ }
213
+
214
+ const body = await getBonfireRss(org); // getText THROWS on 429/5xx/404/timeout (P2)
215
+
216
+ // P4: a 200 body must be RSS (no <rss/<channel ⇒ drift; e.g. an error/HTML page).
217
+ if (!/<rss[\s>]/i.test(body) || !/<channel[\s>]/i.test(body)) {
218
+ throw driftError(bonfireLabel(org), "Bonfire returned a non-RSS body at HTTP 200 — schema drift (expected an <rss><channel> feed).");
219
+ }
220
+
221
+ // Tolerate attributes on the opening <item …> tag (consistent with the <channel[\s>]
222
+ // drift guard and the inner tag() matcher). RSS 2.0 <item> has no standard attributes,
223
+ // but a namespaced/extended feed could add them — a bare-<item> regex would silently
224
+ // DROP such items and undercount totalAvailable (a latent P1 risk caught in dogfooding).
225
+ const items = body.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi) ?? [];
226
+ const all = items.map(mapItem);
227
+ // P1: the RSS is the COMPLETE open set ⇒ totalAvailable = item count (true total).
228
+ const totalAvailable = all.length;
229
+ const pageRows = all.slice(offset, offset + limit);
230
+ const returned = pageRows.length;
231
+ const hasMore = offset + returned < totalAvailable;
232
+
233
+ return withMeta(
234
+ { org, opportunities: pageRows },
235
+ {
236
+ source: BONFIRE_SOURCE(org),
237
+ keylessMode: true,
238
+ returned,
239
+ totalAvailable,
240
+ filtersApplied: ["org"],
241
+ filtersDropped: [],
242
+ fieldsUnavailable: [],
243
+ pagination: { offset, limit, hasMore, nextOffset: hasMore ? offset + returned : null },
244
+ notes: [
245
+ "The RSS is the COMPLETE set of the org's currently-OPEN opportunities (no server pagination) — totalAvailable is the exact open-opportunity count, and this tool pages over it client-side. An empty feed (returned 0) means the org has no open opportunities right now. closeDate is parsed best-effort from the description text.",
246
+ ],
247
+ } satisfies Partial<ResponseMeta>,
248
+ );
249
+ }
package/src/cbp-border.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
3
- * LOGISTICS lane. Live commercial-vehicle (and passenger) wait times at every US
3
+ * LOGISTICS lane. Live COMMERCIAL-VEHICLE (freight-truck) wait times at every US
4
4
  * land border port (Canadian + Mexican): per-port lane delays, operational status,
5
- * and open-lane counts. Answers "what's the current commercial-truck delay at port
5
+ * and open-lane counts. (The raw feed also carries passenger/pedestrian lanes, but
6
+ * this tool surfaces ONLY the commercial-vehicle lanes — the freight lane.)
7
+ * Answers "what's the current commercial-truck delay at port
6
8
  * X" — real-time freight-crossing situational awareness for logistics/trade vendors.
7
9
  *
8
10
  * SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
@@ -127,9 +129,11 @@ async function loadPorts(): Promise<CbpPort[]> {
127
129
 
128
130
  // ─── Tool: cbp_border_wait_times ──────────────────────────────────
129
131
  /**
130
- * List CBP land-border-port commercial-vehicle (+ passenger) wait times, optionally
131
- * filtered by border (Canadian/Mexican) and/or port name (substring). Client-side
132
- * filter over the live feed; honest `_meta` (exact match total + real-time freshness).
132
+ * List CBP land-border-port COMMERCIAL-VEHICLE (freight-truck) wait times, optionally
133
+ * filtered by border (Canadian/Mexican) and/or port name (substring, applied
134
+ * CLIENT-SIDE over the full fetched set and disclosed as such). Passenger/pedestrian
135
+ * lanes are NOT surfaced (freight lane only). Honest `_meta` (exact match total +
136
+ * real-time freshness).
133
137
  */
134
138
  export async function borderWaitTimes(args: {
135
139
  border?: string;
@@ -142,10 +146,20 @@ export async function borderWaitTimes(args: {
142
146
  const all = await loadPorts();
143
147
 
144
148
  const filtersApplied: string[] = [];
149
+ const filtersDropped: string[] = [];
145
150
  const borderQ = args.border?.trim().toLowerCase();
146
151
  const portQ = args.portName?.trim().toLowerCase();
147
- if (args.border !== undefined) filtersApplied.push("border");
148
- if (args.portName !== undefined) filtersApplied.push("portName");
152
+ // [filter honesty] Only claim a filter APPLIED when its query is non-empty — a
153
+ // border:"" / portName:"" (or whitespace) narrows nothing, so reporting it as
154
+ // applied while returning every port would be a false filtersApplied.
155
+ if (args.border !== undefined) {
156
+ if (borderQ) filtersApplied.push("border");
157
+ else filtersDropped.push("border(empty)");
158
+ }
159
+ if (args.portName !== undefined) {
160
+ if (portQ) filtersApplied.push("portName");
161
+ else filtersDropped.push("portName(empty)");
162
+ }
149
163
 
150
164
  const matched = all.filter((p) => {
151
165
  if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
@@ -159,6 +173,13 @@ export async function borderWaitTimes(args: {
159
173
  const hasMore = offset + returned < totalAvailable;
160
174
  const nextOffset = hasMore ? offset + returned : null;
161
175
 
176
+ const notes: string[] = [PROVENANCE_NOTE, FRESHNESS_NOTE];
177
+ if (filtersApplied.length > 0) {
178
+ notes.push(
179
+ "border / portName are applied CLIENT-SIDE over the full live port set (fetched in ONE request — the CBP feed has no server-side filter); totalAvailable is the EXACT matched count over that full set.",
180
+ );
181
+ }
182
+
162
183
  return withMeta(
163
184
  { ports: page },
164
185
  {
@@ -168,10 +189,10 @@ export async function borderWaitTimes(args: {
168
189
  totalAvailable,
169
190
  truncated: hasMore,
170
191
  filtersApplied,
171
- filtersDropped: [],
192
+ filtersDropped,
172
193
  fieldsUnavailable: [],
173
194
  pagination: { offset, limit, hasMore, nextOffset },
174
- notes: [PROVENANCE_NOTE, FRESHNESS_NOTE],
195
+ notes,
175
196
  } satisfies Partial<ResponseMeta>,
176
197
  );
177
198
  }
package/src/gao.ts CHANGED
@@ -593,6 +593,21 @@ export async function gaoProtestLookup(args: {
593
593
 
594
594
  // ── Feed path ──────────────────────────────────────────────────────
595
595
  const xml = await getText(RSS_URL, "gao:rss");
596
+ // [P2/P4] A 200 body that isn't the RSS feed (GAO's edge is Cloudflare/WAF — a
597
+ // challenge or maintenance interstitial returns 200 HTML, NOT the feed) must
598
+ // NOT be parsed into an empty decision list: parseFeed's <item> regex yields []
599
+ // on any non-RSS body, which would read as "no recent bid protests" — an
600
+ // outage-as-empty lie. A real feed always carries <rss>/<channel>/<item> (a
601
+ // genuinely EMPTY feed still has <channel>, so a 0-item feed passes). Guard the
602
+ // shape and surface a retryable outage instead of a fabricated empty result.
603
+ if (!/<(rss|feed|channel|item)\b/i.test(xml)) {
604
+ throw new ToolErrorCarrier({
605
+ kind: "upstream_unavailable",
606
+ retryable: true,
607
+ message: `GAO RSS returned a 200 body with no <rss>/<channel>/<item> markup (${xml.length} bytes) — GAO's Cloudflare/WAF edge most likely served a challenge or maintenance interstitial instead of the feed. Surfaced as a retryable outage rather than a fabricated empty decision list (which would falsely read as "no recent bid protests"). Retry shortly.`,
608
+ upstreamEndpoint: "gao:rss",
609
+ });
610
+ }
596
611
  const rawItems = parseFeed(xml);
597
612
  const protests = rawItems.filter(isBidProtest);
598
613
 
@@ -653,9 +668,16 @@ export async function gaoProtestLookup(args: {
653
668
 
654
669
  // Apply the outcome filter now that we (may) have parsed outcomes, then cap.
655
670
  const filtersDropped: string[] = [];
671
+ let outcomeUndetermined = 0;
656
672
  if (outcomeFilter) {
657
673
  if (enrich) {
658
674
  filtersApplied.push("outcome(from decision page)");
675
+ // [filter honesty] A decision whose page could not be enriched has
676
+ // outcome=null — UNDETERMINED, not confirmed "≠ outcomeFilter". Dropping
677
+ // those silently makes returned:0 read as "no <outcome> protests exist"
678
+ // when the truth is "no outcome could be read" (GAO per-decision pages are
679
+ // intermittently WAF-blocked). Count them so it is disclosed, not swallowed.
680
+ outcomeUndetermined = decisions.filter((d) => d.outcome === null).length;
659
681
  decisions = decisions.filter((d) => d.outcome === outcomeFilter);
660
682
  } else {
661
683
  // Can't determine outcome without enrichment → don't silently pretend to.
@@ -672,6 +694,11 @@ export async function gaoProtestLookup(args: {
672
694
  `${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`,
673
695
  );
674
696
  }
697
+ if (outcomeFilter && enrich && outcomeUndetermined > 0) {
698
+ notes.push(
699
+ `${outcomeUndetermined} recent decision(s) were EXCLUDED from the outcome='${outcomeFilter}' filter because their decision page could not be read (enrichment failed) — their outcome is UNDETERMINED, not confirmed to differ. Treat a 0 or low result here as "outcome could not be determined for those", NOT "none exist"; retry (GAO per-decision pages are intermittently WAF-blocked) or drop the outcome filter to list all recent decisions.`,
700
+ );
701
+ }
675
702
  if (!enrich) {
676
703
  notes.push(
677
704
  "enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.",
package/src/grants.ts CHANGED
@@ -139,7 +139,7 @@ export async function searchGrants(args: {
139
139
  const notes: string[] = [];
140
140
  if (args.agency || args.cfda) {
141
141
  notes.push(
142
- "Grants.gov silently ignores an unknown agency code or CFDA number (it returns the UNFILTERED result set instead of an error) and does not confirm which filters were honored — if the result count looks too broad, verify the agency/CFDA value.",
142
+ "Grants.gov applies the agency/CFDA filter server-side (live-verified 2026-07-20): a bogus or misspelled value returns 0 results, NOT an error and NOT the unfiltered set — so an unexpectedly EMPTY filtered search most often means the agency/CFDA value is invalid, not that no grants exist. `filtersApplied` reflects that the filter was sent. Verify the agency code / CFDA number if a filtered result is surprisingly empty.",
143
143
  );
144
144
  }
145
145
  // VQ-1 (C82 dogfooding): Grants.gov OR-tokenizes multi-word keywords (matches ANY
@@ -57,8 +57,26 @@ const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
57
57
  const GSA_PERDIEM_SOURCE = (mode: string) =>
58
58
  `${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
59
59
 
60
- // The default per-diem fiscal year (ADR-0050 — the current confirmed vintage).
61
- export const DEFAULT_PERDIEM_YEAR = "2025";
60
+ /**
61
+ * The US federal fiscal year for a date (ADR-0050). The FY begins Oct 1, so
62
+ * Oct–Dec belong to the NEXT calendar year's FY (e.g. 2025-11 → FY2026) while
63
+ * Jan–Sep stay in the current (e.g. 2026-07 → FY2026). Pure + UTC-based so it is
64
+ * deterministic and timezone-independent (the intra-day rollover instant is
65
+ * immaterial — per-diem rates do not change within a day).
66
+ */
67
+ export function federalFiscalYear(d: Date): number {
68
+ return d.getUTCMonth() >= 9 ? d.getUTCFullYear() + 1 : d.getUTCFullYear();
69
+ }
70
+
71
+ /**
72
+ * The default per-diem year when the caller omits `year`: the CURRENT federal
73
+ * fiscal year. GSA publishes rates per FY; a hard-coded default silently serves
74
+ * an EXPIRED vintage once the FY rolls over (the drift this replaces — a no-year
75
+ * lookup must track the live FY, not a frozen year).
76
+ */
77
+ export function defaultPerdiemYear(): string {
78
+ return String(federalFiscalYear(new Date()));
79
+ }
62
80
 
63
81
  // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
64
82
  // Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
@@ -158,7 +176,7 @@ async function getGsaPerdiem(path: string): Promise<unknown> {
158
176
 
159
177
  /**
160
178
  * Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
161
- * given `year` (default 2025). Returns flattened rate rows (each outer state/year
179
+ * given `year` (default: the current U.S. federal fiscal year). Returns flattened rate rows (each outer state/year
162
180
  * group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
163
181
  * pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
164
182
  * as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
@@ -168,7 +186,8 @@ export async function perdiemRates(
168
186
  args: GsaPerdiemRatesArgs,
169
187
  ): Promise<MetaBundle> {
170
188
  const label = GSA_PERDIEM_LABEL;
171
- const year = args.year ?? DEFAULT_PERDIEM_YEAR;
189
+ const yearWasDefaulted = args.year === undefined;
190
+ const year = args.year ?? defaultPerdiemYear();
172
191
 
173
192
  // ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
174
193
  // caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
@@ -341,6 +360,11 @@ export async function perdiemRates(
341
360
 
342
361
  const returned = rows.length;
343
362
  const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
363
+ if (yearWasDefaulted) {
364
+ notes.push(
365
+ `No \`year\` was supplied, so it defaulted to the CURRENT U.S. federal fiscal year (FY${year}). GSA per-diem ceilings are set per fiscal year (Oct 1–Sep 30); pass an explicit \`year\` for a prior or upcoming FY.`,
366
+ );
367
+ }
344
368
  pushKeyNote(notes);
345
369
 
346
370
  return withMeta(
package/src/integrity.ts CHANGED
@@ -294,7 +294,22 @@ export async function checkExclusions(args: {
294
294
  const serverTruncated =
295
295
  totalElements !== null && rawResults.length < totalElements;
296
296
  const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
297
- const truncated = serverTruncated || hitCap || postFiltered;
297
+ // totalAvailable HONESTY: the raw free-text `totalElements` counts every record
298
+ // that merely shares a WORD with the query — NOT the name-gated matches this
299
+ // tool reports. Surfacing it read as "0 of 252 matches, incomplete" for a firm
300
+ // with ZERO real matches (a vetting tool must not overstate match availability).
301
+ // When the result is name-gated (the normal case — a selector is always
302
+ // required), the true count of name-MATCHING exclusions is genuinely unknown
303
+ // from one page of text hits, so report null (never the free-text total); the
304
+ // per-page match count stays in data.matchCount.
305
+ const totalAvailable = postFiltered ? null : totalElements;
306
+ // truncated only when there is genuinely more to see: more server pages, the
307
+ // deep-paging cap, or this page dropped loose text hits (so a name VARIANT
308
+ // could match on a later page). The old `|| postFiltered` forced truncated:true
309
+ // even over a genuinely empty result set (0 text hits) — asserting incompleteness
310
+ // over an empty set, a contradiction. A fully-consumed name-gated result is a
311
+ // complete, honest empty.
312
+ const truncated = serverTruncated || hitCap || (postFiltered && looseTextHits > 0);
298
313
 
299
314
  const notes: string[] = [NOT_PROOF_NOTE];
300
315
  notes.push(
@@ -307,7 +322,7 @@ export async function checkExclusions(args: {
307
322
  }
308
323
  if (postFiltered) {
309
324
  notes.push(
310
- "A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true match count for the combined filter may exceed this page. Narrow with a more specific `query` or raise `size`.",
325
+ "A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true name-matched total is unknown from one page, so `totalAvailable` is null (the raw free-text hit count is NOT the match count). `matchCount` is this page's name-gated matches; narrow with a more specific `query` or raise `size`.",
311
326
  );
312
327
  }
313
328
  if (hitCap) {
@@ -333,7 +348,7 @@ export async function checkExclusions(args: {
333
348
  source: EXCLUSIONS_SOURCE,
334
349
  keylessMode: true,
335
350
  returned: records.length,
336
- totalAvailable: totalElements,
351
+ totalAvailable,
337
352
  truncated,
338
353
  pagination: {
339
354
  offset: page * size,
package/src/lda.ts CHANGED
@@ -173,7 +173,7 @@ export type LdaSearchFilingsArgs = {
173
173
  lobbyistName?: string;
174
174
  filingYear?: string; // ^\d{4}$
175
175
  filingType?: string; // a short code (e.g. "Q1", "RR")
176
- agency?: string; // → government_entity (the federal entity lobbied)
176
+ agency?: string; // NOT server-filterable on /filings/ (API silently ignores government_entity) — never sent upstream; reported in _meta.filtersDropped with a workaround note.
177
177
  issue?: string; // → filing_specific_lobbying_issues
178
178
  page?: number; // 1-based, default 1
179
179
  pageSize?: number; // 1..25, default 25
@@ -218,11 +218,22 @@ export async function searchFilings(
218
218
  setFilter("lobbyist_name", args.lobbyistName, "lobbyistName");
219
219
  setFilter("filing_year", args.filingYear, "filingYear");
220
220
  setFilter("filing_type", args.filingType, "filingType");
221
- setFilter("government_entity", args.agency, "agency");
222
221
  setFilter("filing_specific_lobbying_issues", args.issue, "issue");
223
222
  params.set("page", String(page));
224
223
  params.set("page_size", String(pageSize));
225
224
 
225
+ // [filter honesty] `agency` maps to NO server-side filter on /filings/: the LDA
226
+ // API silently ignores `government_entity` (live-verified — adding it does NOT
227
+ // narrow `count`; government entities are nested per lobbying activity, not a
228
+ // top-level filter). So we NEVER send it (an ignored param buys nothing) and
229
+ // disclose it as DROPPED — reporting it as applied while returning the full
230
+ // corpus would be a silent-filter-drop lie (filtersApplied + totalAvailable
231
+ // overstated as if agency-scoped).
232
+ const filtersDropped: string[] = [];
233
+ if (args.agency !== undefined && args.agency !== "") {
234
+ filtersDropped.push("agency");
235
+ }
236
+
226
237
  const url = `https://${LDA_HOST}${LDA_FILINGS_PATH}?${params.toString()}`;
227
238
  // Belt-and-suspenders: the fixed host + strictly-built query leave nothing to
228
239
  // steer the authority; assert the built URL cannot have been moved off-host.
@@ -266,8 +277,8 @@ export async function searchFilings(
266
277
  kind: "invalid_input",
267
278
  retryable: false,
268
279
  message: apiMsg
269
- ? `LDA rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (filingYear, filingType, agency, issue, …).`
270
- : "LDA rejected the request (HTTP 400) — check the filter parameters (filingYear, filingType, agency, issue, …).",
280
+ ? `LDA rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (filingYear, filingType, issue, …).`
281
+ : "LDA rejected the request (HTTP 400) — check the filter parameters (filingYear, filingType, issue, …).",
271
282
  upstreamStatus: 400,
272
283
  upstreamEndpoint: LDA_FILINGS_LABEL,
273
284
  });
@@ -310,6 +321,11 @@ export async function searchFilings(
310
321
  `This is page ${page} (pageSize ${pageSize}) of ~${Math.ceil(totalAvailable / pageSize)} — pass page=${page + 1} for the next page.`,
311
322
  );
312
323
  }
324
+ if (filtersDropped.includes("agency")) {
325
+ notes.push(
326
+ "The `agency` filter was NOT applied: the keyless LDA /filings/ endpoint has no server-side government-entity filter (the API silently ignores it and returns the full corpus), so it is reported in filtersDropped rather than falsely narrowed — totalAvailable and filtersApplied reflect ONLY the filters actually applied. Government entities are nested per lobbying activity: each returned filing's lobbyingActivities[].governmentEntities lists them. To find lobbying that targeted a specific agency, narrow by registrantName/clientName/issue and inspect those nested governmentEntities.",
327
+ );
328
+ }
313
329
 
314
330
  return withMeta(
315
331
  { filings },
@@ -319,7 +335,7 @@ export async function searchFilings(
319
335
  returned,
320
336
  totalAvailable,
321
337
  filtersApplied,
322
- filtersDropped: [],
338
+ filtersDropped,
323
339
  fieldsUnavailable: [],
324
340
  pagination: { offset, limit: pageSize, hasMore, nextOffset },
325
341
  notes,