@cliwant/mcp-sam-gov 0.3.0 → 1.0.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 (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
@@ -13,6 +13,7 @@
13
13
  * to layer 2 transparently. Callers don't have to care.
14
14
  */
15
15
 
16
+ import { ToolErrorCarrier } from "../errors.js";
16
17
  import type {
17
18
  EntitySearchResult,
18
19
  SamGovClientOptions,
@@ -41,13 +42,26 @@ export class SamGovClient {
41
42
  this.logger = options.logger ?? {};
42
43
  }
43
44
 
45
+ /**
46
+ * True when no SAM API key is configured, i.e. requests fall back to the
47
+ * keyless HAL layer. The keyless list endpoint ignores the structured
48
+ * facet filters (NAICS / set-aside / state / org) and nulls those fields,
49
+ * so tools use this to populate an honest `_meta` (filtersDropped /
50
+ * fieldsUnavailable). See docs/research/02-truthful-outputs-spec.md §1.2.
51
+ */
52
+ get isKeyless(): boolean {
53
+ return !this.apiKey;
54
+ }
55
+
44
56
  /**
45
57
  * Search SAM.gov opportunities.
46
58
  *
47
59
  * Three-tier fallback:
48
60
  * 1. Authenticated v2 search (if `apiKey` configured)
49
- * 2. Keyless HAL search
50
- * 3. Empty result (caller can decide how to surface "no data")
61
+ * 2. Keyless HAL search — returned AS-IS (a genuine 0 or an empty page
62
+ * past the end is an honest result, not a fallback trigger)
63
+ * 3. Only if EVERY tier throws (total outage): an empty result carrying
64
+ * `degraded` so the caller surfaces an outage, NOT a confirmed zero.
51
65
  */
52
66
  async searchOpportunities(
53
67
  filters: SamSearchFilters,
@@ -65,17 +79,26 @@ export class SamGovClient {
65
79
  }
66
80
  }
67
81
  try {
68
- const r = await this.searchPublic(filters);
69
- if (r.opportunitiesData.length > 0) return r;
82
+ // Return the public result AS-IS — even 0 rows. A genuine zero
83
+ // (source healthy, query matched nothing) and a real `totalRecords>0`
84
+ // but empty page (paging past the end) are both HONEST outcomes and
85
+ // must not be replaced by a hardcoded 0. Only a THROW below (all tiers
86
+ // failed) is an outage, which we mark `degraded` so callers can tell it
87
+ // apart from a genuine zero instead of silently reporting "0, complete".
88
+ return await this.searchPublic(filters);
70
89
  } catch (err) {
71
90
  this.warn("public search failed", err);
91
+ return {
92
+ totalRecords: 0,
93
+ limit: filters.limit ?? 25,
94
+ offset: filters.offset ?? 0,
95
+ opportunitiesData: [],
96
+ degraded: {
97
+ reason:
98
+ "SAM opportunity search is unavailable (all access tiers failed).",
99
+ },
100
+ };
72
101
  }
73
- return {
74
- totalRecords: 0,
75
- limit: filters.limit ?? 25,
76
- offset: filters.offset ?? 0,
77
- opportunitiesData: [],
78
- };
79
102
  }
80
103
 
81
104
  /**
@@ -106,7 +129,18 @@ export class SamGovClient {
106
129
  const hit = json.opportunitiesData?.[0];
107
130
  if (hit) {
108
131
  if (!hit.resourceLinks || hit.resourceLinks.length === 0) {
109
- hit.resourceLinks = await this.getPublicResourceLinks(noticeId);
132
+ // Same DOWN-reads-as-absent guard on the keyed path: a failed
133
+ // resource-list fetch is an outage, not "no attachments" —
134
+ // record the degradation instead of silently returning `[]`.
135
+ hit.resourceLinks = await this.getPublicResourceLinks(
136
+ noticeId,
137
+ ).catch(() => {
138
+ hit.enrichmentDegraded = [
139
+ ...(hit.enrichmentDegraded ?? []),
140
+ "attachments",
141
+ ];
142
+ return [];
143
+ });
110
144
  }
111
145
  return hit;
112
146
  }
@@ -115,11 +149,25 @@ export class SamGovClient {
115
149
  this.warn("auth getOpportunity failed, trying public", err);
116
150
  }
117
151
  }
152
+ // getOpportunityPublic returns null ONLY for a genuine not-found (401/404);
153
+ // an outage (5xx/network/timeout/hollow-200) THROWS a classified
154
+ // ToolErrorCarrier. That throw MUST propagate — collapsing it to null would
155
+ // make the wrapper render a DOWN service as found:false (a fabricated
156
+ // absence). A genuine null still passes through unchanged.
118
157
  try {
119
158
  return await this.getOpportunityPublic(noticeId);
120
159
  } catch (err) {
121
160
  this.warn("public getOpportunity failed", err);
122
- return null;
161
+ if (err instanceof ToolErrorCarrier) throw err;
162
+ // A raw/unclassified error must not become a silent null either —
163
+ // classify it as a retryable outage so the caller learns the truth.
164
+ throw new ToolErrorCarrier({
165
+ kind: "upstream_unavailable",
166
+ message: `SAM detail lookup for ${noticeId} failed: ${(err as Error).message}. This is an outage, not a confirmed absence. Retry.`,
167
+ retryable: true,
168
+ retryAfterSeconds: 30,
169
+ upstreamEndpoint: "sam:opps/v2/opportunities",
170
+ });
123
171
  }
124
172
  }
125
173
 
@@ -153,7 +201,19 @@ export class SamGovClient {
153
201
  }
154
202
  try {
155
203
  const r = await this.fetchImpl(finalUrl, { headers });
156
- if (!r.ok) return "Description not available.";
204
+ // A DOWN description fetch must ERROR, never fabricate a "not available"
205
+ // placeholder that reads as "this description doesn't exist". THROW a
206
+ // classified retryable outage; the wrapper propagates it to a tool error.
207
+ if (!r.ok) {
208
+ throw new ToolErrorCarrier({
209
+ kind: "upstream_unavailable",
210
+ message: `SAM description fetch returned HTTP ${r.status} for ${finalUrl} — the service is unavailable, NOT an absent description. Retry.`,
211
+ retryable: true,
212
+ retryAfterSeconds: 60,
213
+ upstreamStatus: r.status,
214
+ upstreamEndpoint: "sam:description",
215
+ });
216
+ }
157
217
  const ct = r.headers.get("content-type") ?? "";
158
218
  if (ct.includes("application/json") || ct.includes("application/hal+json")) {
159
219
  const json = (await r.json()) as {
@@ -181,7 +241,18 @@ export class SamGovClient {
181
241
  .trim();
182
242
  } catch (err) {
183
243
  this.warn("fetchOpportunityDescription failed", err);
184
- return "Description not available.";
244
+ // A classified outage (from the !r.ok throw above) propagates as-is. A raw
245
+ // network/parse fault must ALSO error (not fabricate "not available") —
246
+ // classify it as a retryable outage so a DOWN fetch is never read as an
247
+ // absent description.
248
+ if (err instanceof ToolErrorCarrier) throw err;
249
+ throw new ToolErrorCarrier({
250
+ kind: "upstream_unavailable",
251
+ message: `SAM description fetch for ${finalUrl} failed: ${(err as Error).message}. This is an outage, not an absent description. Retry.`,
252
+ retryable: true,
253
+ retryAfterSeconds: 30,
254
+ upstreamEndpoint: "sam:description",
255
+ });
185
256
  }
186
257
  }
187
258
 
@@ -288,14 +359,41 @@ export class SamGovClient {
288
359
  url.searchParams.set("sort", "-modifiedDate");
289
360
  url.searchParams.set("size", String(filters.limit ?? 25));
290
361
  url.searchParams.set("is_active", "true");
362
+ // Keyless HAL facet params — VERIFIED LIVE (2026-07). The list endpoint
363
+ // honors these server-side: result counts drop correctly AND every returned
364
+ // notice's detail matches the filter (e.g. naics=236220 → all hits carry
365
+ // primary NAICS 236220). The param NAMES differ from the authenticated v2
366
+ // API's, and a wrong name is SILENTLY IGNORED (returns the full firehose):
367
+ // NAICS → `naics` (NOT `naics_code`/`ncode` — both ignored)
368
+ // place-of-perf → `pop_state` (NOT `place_of_performance_state`; value
369
+ // must be the UPPER-CASE 2-letter code)
370
+ // set-aside → `set_aside` (repeatable; SAM codes: SBA/8A/HZS/HZC/
371
+ // SDVOSBC/WOSB/EDWOSB/VSA/VSS — all verified)
372
+ // keyword → `q`
373
+ // Organization-name has NO keyless filter param (organization_name and
374
+ // organizationName are both ignored) — sent best-effort; the tool's `_meta`
375
+ // flags it as dropped so the AI never treats the set as org-filtered.
291
376
  if (filters.query) url.searchParams.set("q", filters.query);
292
- if (filters.ncode) url.searchParams.append("naics_code", filters.ncode);
377
+ if (filters.ncode) url.searchParams.append("naics", filters.ncode);
293
378
  if (filters.organizationName)
294
379
  url.searchParams.set("organization_name", filters.organizationName);
295
380
  if (filters.setAside?.length)
296
381
  for (const sa of filters.setAside) url.searchParams.append("set_aside", sa);
297
382
  if (filters.state)
298
- url.searchParams.set("place_of_performance_state", filters.state);
383
+ url.searchParams.set("pop_state", filters.state.toUpperCase());
384
+ // Notice-type facet — VERIFIED LIVE (2026-07): the keyless list endpoint
385
+ // filters SERVER-SIDE on `notice_type` (comma-joined multi-value). Codes:
386
+ // r=Sources Sought, p=Presolicitation, s=Special Notice, k=Combined
387
+ // Synopsis/Solicitation, i=Intent to Bundle, u=Justification(J&A),
388
+ // o=Solicitation, a=Award. (e.g. notice_type=r → 3,641; r,p,s → 10,603;
389
+ // p&naics=541512 → 8 — counts drop correctly and every row matches.) The
390
+ // keyless param is `notice_type` — NOT `ptype` (the AUTHENTICATED endpoint's
391
+ // name, sent by buildAuthSearchUrl). ADDITIVE: existing callers that don't
392
+ // set `ptype` (e.g. sam_search_opportunities) send nothing here, so their
393
+ // behavior is UNCHANGED. Powers the pre-solicitation shaping radar
394
+ // (sam_search_shaping).
395
+ if (filters.ptype?.length)
396
+ url.searchParams.set("notice_type", filters.ptype.join(","));
299
397
 
300
398
  const r = await this.fetchImpl(url.toString(), {
301
399
  headers: this.publicHeaders(),
@@ -317,7 +415,21 @@ export class SamGovClient {
317
415
  }[];
318
416
  };
319
417
  };
320
- const totalRecords = json.page?.totalElements ?? 0;
418
+ // A 200 whose body lacks a well-formed HAL `page` block is NOT a genuine
419
+ // zero — it's a hollow/degraded response (a CloudFront/Envoy cached error
420
+ // envelope, a `{"message":"Access Denied"}` 200, or a dropped-`page` proxy
421
+ // body; the endpoint sits behind CloudFront→istio-envoy). A GENUINE empty
422
+ // result always carries `page.totalElements` (0); a healthy hit carries a
423
+ // positive one. So treat a non-finite `totalElements` as an OUTAGE (throw)
424
+ // — searchOpportunities' catch then marks it `degraded` instead of emitting
425
+ // the "0 notices, complete" lie. Mirrors far.ts's hollow-200 guard.
426
+ const totalElements = json.page?.totalElements;
427
+ if (!Number.isFinite(totalElements)) {
428
+ throw new Error(
429
+ "SAM.gov public search returned HTTP 200 without a valid page.totalElements — hollow/degraded body, not a genuine zero.",
430
+ );
431
+ }
432
+ const totalRecords = totalElements as number;
321
433
  const results = json._embedded?.results ?? [];
322
434
  const data: SamOpportunity[] = results.map((r) => {
323
435
  const hierarchy = (r.organizationHierarchy ?? [])
@@ -355,8 +467,57 @@ export class SamGovClient {
355
467
  noticeId: string,
356
468
  ): Promise<SamOpportunity | null> {
357
469
  const url = `${PUBLIC_BASE}/opps/v2/opportunities/${encodeURIComponent(noticeId)}`;
358
- const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
359
- if (!r.ok) return null;
470
+ // A network-level fault (DNS/socket/timeout) must surface as a classified
471
+ // retryable outage, NOT collapse to null (which the wrapper renders as
472
+ // "notice not found"). Mirrors far.ts/fetchWithRetry's network branch.
473
+ let r: Response;
474
+ try {
475
+ r = await this.fetchImpl(url, { headers: this.publicHeaders() });
476
+ } catch (err) {
477
+ throw new ToolErrorCarrier({
478
+ kind: "upstream_unavailable",
479
+ message: `Network error reaching the SAM detail endpoint for ${noticeId}: ${(err as Error).message}. This is an outage, not a confirmed absence. Retry.`,
480
+ retryable: true,
481
+ retryAfterSeconds: 30,
482
+ upstreamEndpoint: "sam:opps/v2/opportunities",
483
+ });
484
+ }
485
+ // LIVE-GROUNDED mapping (re-verified 2026-07-04): a real 32-hex id → 200 +
486
+ // data2.title, STABLE; the endpoint's ABSENT vocabulary (across ~20 bogus/
487
+ // malformed/hostile ids, incl. SQLi/XSS/traversal) is STRICTLY:
488
+ // • 401 UNAUTHORIZED "Error occured while get..." (most bogus/malformed ids)
489
+ // • 400 BAD_REQUEST "Record not found / Invalid request data"
490
+ // • 404 (documented not-found)
491
+ // and its OUTAGE vocabulary is 5xx (a hostile payload live-returned a 502).
492
+ // 403 was NEVER emitted; a 403 here would be a CDN/WAF block = an OUTAGE, not
493
+ // an absence. So ONLY the three CONFIRMED absent statuses {400,401,404} → null
494
+ // (→ wrapper found:false); 429 → rate_limited (a retryable throttle, not an
495
+ // absence); and EVERY OTHER non-2xx — 403, other 4xx (410/422/451/…), all 5xx,
496
+ // network, timeout, hollow-200 — THROWS upstream_unavailable. This honors the
497
+ // invariant "a DOWN service must NEVER read as absent" and errs toward
498
+ // retryable-outage for any ambiguous status (the safe direction: a spurious
499
+ // "retry" is far less harmful than a fabricated "does not exist").
500
+ if (r.status === 429) {
501
+ throw new ToolErrorCarrier({
502
+ kind: "rate_limited",
503
+ message: `SAM detail endpoint rate-limited (HTTP 429) for ${noticeId}. Retry after a short back-off.`,
504
+ retryable: true,
505
+ retryAfterSeconds: 30,
506
+ upstreamStatus: 429,
507
+ upstreamEndpoint: "sam:opps/v2/opportunities",
508
+ });
509
+ }
510
+ if (r.status === 400 || r.status === 401 || r.status === 404) return null;
511
+ if (!r.ok) {
512
+ throw new ToolErrorCarrier({
513
+ kind: "upstream_unavailable",
514
+ message: `SAM detail endpoint returned HTTP ${r.status} for ${noticeId} — the service is unavailable, NOT a confirmed absence. Retry.`,
515
+ retryable: true,
516
+ retryAfterSeconds: 60,
517
+ upstreamStatus: r.status,
518
+ upstreamEndpoint: "sam:opps/v2/opportunities",
519
+ });
520
+ }
360
521
  type DetailResp = {
361
522
  data2?: {
362
523
  title?: string;
@@ -380,13 +541,57 @@ export class SamGovClient {
380
541
  };
381
542
  description?: { body?: string }[];
382
543
  };
383
- const detail = (await r.json()) as DetailResp;
544
+ let detail: DetailResp;
545
+ try {
546
+ detail = (await r.json()) as DetailResp;
547
+ } catch (err) {
548
+ // A 200 whose body won't parse is a hollow/degraded response (CDN/proxy),
549
+ // NOT a genuine absence — classify as a retryable outage.
550
+ throw new ToolErrorCarrier({
551
+ kind: "upstream_unavailable",
552
+ message: `SAM detail endpoint returned HTTP 200 for ${noticeId} but the body could not be parsed as JSON (${(err as Error).message}) — hollow/degraded response, NOT a confirmed absence. Retry.`,
553
+ retryable: true,
554
+ retryAfterSeconds: 60,
555
+ upstreamStatus: 200,
556
+ upstreamEndpoint: "sam:opps/v2/opportunities",
557
+ });
558
+ }
384
559
  const d = detail.data2 ?? {};
385
- if (!d.title) return null;
560
+ // A 200 WITHOUT a usable notice body (no data2.title) is a hollow/degraded
561
+ // response — a CDN/proxy cached error envelope or dropped body — NOT a real
562
+ // "this notice does not exist". Real notices are stably 200+title; absent
563
+ // ids 401. So THROW (retryable), mirroring the searchPublic/far hollow-200
564
+ // guards, instead of returning null (which the wrapper would render as
565
+ // found:false — a fabricated absence over an outage).
566
+ if (!d.title) {
567
+ throw new ToolErrorCarrier({
568
+ kind: "upstream_unavailable",
569
+ message: `SAM detail returned HTTP 200 without a usable notice body (no data2.title) for ${noticeId} — hollow/degraded response, NOT a confirmed absence. Retry.`,
570
+ retryable: true,
571
+ retryAfterSeconds: 60,
572
+ upstreamStatus: 200,
573
+ upstreamEndpoint: "sam:opps/v2/opportunities",
574
+ });
575
+ }
576
+ // Each enrichment sub-fetch is caught INDIVIDUALLY: an outage on one must
577
+ // neither sink the notice (the primary fields still return) nor silently
578
+ // zero the field (a swallowed `[]`/`""` would read as "no attachments"/"no
579
+ // org"). We record WHICH bucket degraded so the wrapper can disclose an
580
+ // honest `_meta.degraded` + a note ("MAY have attachments; retry — NOT a
581
+ // confirmation it has none"), and never flag the OTHER (healthy) enrichment.
582
+ const enrichmentDegraded: string[] = [];
386
583
  const [resourceLinks, fullParentPathName] = await Promise.all([
387
- this.getPublicResourceLinks(noticeId),
584
+ this.getPublicResourceLinks(noticeId).catch((e) => {
585
+ this.warn("resourceLinks enrichment failed", e);
586
+ enrichmentDegraded.push("attachments");
587
+ return [] as string[];
588
+ }),
388
589
  d.organizationId
389
- ? this.getPublicOrgName(d.organizationId)
590
+ ? this.getPublicOrgName(d.organizationId).catch((e) => {
591
+ this.warn("orgName enrichment failed", e);
592
+ enrichmentDegraded.push("organization");
593
+ return "";
594
+ })
390
595
  : Promise.resolve(""),
391
596
  ]);
392
597
  return {
@@ -410,54 +615,71 @@ export class SamGovClient {
410
615
  pointOfContact: d.pointOfContact ?? [],
411
616
  uiLink: `https://sam.gov/opp/${noticeId}/view`,
412
617
  resourceLinks,
618
+ enrichmentDegraded: enrichmentDegraded.length
619
+ ? enrichmentDegraded
620
+ : undefined,
413
621
  };
414
622
  }
415
623
 
624
+ /**
625
+ * Fetch the public attachment-download URLs for a notice.
626
+ *
627
+ * TRUTHFULNESS (DOWN-reads-as-absent guard): this is called ONLY after the
628
+ * detail endpoint already 200'd (the notice exists). The resources endpoint
629
+ * then returns HTTP 200 for every real notice — a genuine NO-attachment
630
+ * notice is 200 with an empty list. So any non-200 (or a network fault) here
631
+ * is an OUTAGE, never a genuine "no attachments", and MUST NOT be swallowed
632
+ * into `[]` — that would let a DOWN list-fetch read as "no documents" and an
633
+ * AI skip a solicitation whose RFP it could have read. We THROW on non-200
634
+ * and let a network error propagate; the caller (getOpportunityPublic / the
635
+ * auth tier) catches it INDIVIDUALLY and records the degradation. A 200 →
636
+ * the genuine links, which MAY be `[]` (an honest empty, disclosed as such).
637
+ */
416
638
  private async getPublicResourceLinks(noticeId: string): Promise<string[]> {
417
- try {
418
- const url = `${PUBLIC_BASE}/opps/v3/opportunities/${encodeURIComponent(noticeId)}/resources`;
419
- const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
420
- if (!r.ok) return [];
421
- type Resp = {
422
- _embedded?: {
423
- opportunityAttachmentList?: {
424
- attachments?: { resourceId?: string; name?: string }[];
425
- }[];
426
- };
639
+ const url = `${PUBLIC_BASE}/opps/v3/opportunities/${encodeURIComponent(noticeId)}/resources`;
640
+ const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
641
+ if (!r.ok) throw new Error(`resources HTTP ${r.status}`);
642
+ type Resp = {
643
+ _embedded?: {
644
+ opportunityAttachmentList?: {
645
+ attachments?: { resourceId?: string; name?: string }[];
646
+ }[];
427
647
  };
428
- const json = (await r.json()) as Resp;
429
- const attachments =
430
- json._embedded?.opportunityAttachmentList?.[0]?.attachments ?? [];
431
- return attachments
432
- .filter((a) => a.resourceId)
433
- .map((a) => this.publicDownloadUrl(a.resourceId!));
434
- } catch (err) {
435
- this.warn("getPublicResourceLinks failed", err);
436
- return [];
437
- }
648
+ };
649
+ const json = (await r.json()) as Resp;
650
+ const attachments =
651
+ json._embedded?.opportunityAttachmentList?.[0]?.attachments ?? [];
652
+ return attachments
653
+ .filter((a) => a.resourceId)
654
+ .map((a) => this.publicDownloadUrl(a.resourceId!));
438
655
  }
439
656
 
657
+ /**
658
+ * Resolve an awarding-organization id to its canonical path/name.
659
+ *
660
+ * TRUTHFULNESS (same guard as getPublicResourceLinks): a genuine org with no
661
+ * path → 200 + empty field; a non-200/network fault → an OUTAGE. Do NOT
662
+ * swallow the outage into `""` (that reads as "no organization" when the
663
+ * fetch was DOWN). THROW on non-200; let a network error propagate. The
664
+ * caller catches it INDIVIDUALLY and records the degradation. A 200 → the
665
+ * name, which MAY be `""` (an honest empty).
666
+ */
440
667
  private async getPublicOrgName(orgId: string): Promise<string> {
441
- try {
442
- const url = `${PUBLIC_BASE}/federalorganizations/v1/organizations/${encodeURIComponent(orgId)}`;
443
- const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
444
- if (!r.ok) return "";
445
- type Resp = {
446
- _embedded?: {
447
- org?: {
448
- fullParentPathName?: string;
449
- agencyName?: string;
450
- name?: string;
451
- };
452
- }[];
453
- };
454
- const json = (await r.json()) as Resp;
455
- const org = json._embedded?.[0]?.org;
456
- return org?.fullParentPathName ?? org?.agencyName ?? org?.name ?? "";
457
- } catch (err) {
458
- this.warn("getPublicOrgName failed", err);
459
- return "";
460
- }
668
+ const url = `${PUBLIC_BASE}/federalorganizations/v1/organizations/${encodeURIComponent(orgId)}`;
669
+ const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
670
+ if (!r.ok) throw new Error(`org HTTP ${r.status}`);
671
+ type Resp = {
672
+ _embedded?: {
673
+ org?: {
674
+ fullParentPathName?: string;
675
+ agencyName?: string;
676
+ name?: string;
677
+ };
678
+ }[];
679
+ };
680
+ const json = (await r.json()) as Resp;
681
+ const org = json._embedded?.[0]?.org;
682
+ return org?.fullParentPathName ?? org?.agencyName ?? org?.name ?? "";
461
683
  }
462
684
 
463
685
  private publicHeaders(): HeadersInit {
@@ -472,6 +694,66 @@ export class SamGovClient {
472
694
  }
473
695
  }
474
696
 
697
+ // ─── Shaping-radar pure helpers (exported for offline unit testing) ──────────
698
+
699
+ /**
700
+ * Whole days from `now` to an ISO `responseDeadline`. Returns null when the
701
+ * deadline is missing/unparseable (a null day count is COUNTED, never hidden —
702
+ * the shaping radar surfaces deadline-less notices rather than dropping them).
703
+ * Uses UTC-midnight flooring on both ends so the count is a stable whole number
704
+ * regardless of intraday time-of-day. Negative when the deadline is in the past.
705
+ */
706
+ export function daysUntilResponse(
707
+ deadline: string | null | undefined,
708
+ now: Date = new Date(),
709
+ ): number | null {
710
+ if (!deadline) return null;
711
+ const then = new Date(deadline);
712
+ const t = then.getTime();
713
+ if (Number.isNaN(t)) return null;
714
+ const dayMs = 86_400_000;
715
+ const nowUtc = Date.UTC(
716
+ now.getUTCFullYear(),
717
+ now.getUTCMonth(),
718
+ now.getUTCDate(),
719
+ );
720
+ const thenUtc = Date.UTC(
721
+ then.getUTCFullYear(),
722
+ then.getUTCMonth(),
723
+ then.getUTCDate(),
724
+ );
725
+ return Math.round((thenUtc - nowUtc) / dayMs);
726
+ }
727
+
728
+ /**
729
+ * Client-side response-deadline WINDOW filter for the shaping radar.
730
+ *
731
+ * The keyless SGS feed IGNORES rdlfrom/rdlto (VERIFIED LIVE 2026-07: a
732
+ * notice_type=r query with rdlfrom/rdlto returns the same total and deadlines
733
+ * outside the requested window), so a response-deadline window MUST be applied
734
+ * over the already-fetched page and DISCLOSED (the server flags
735
+ * `_meta.filtersDropped:["responseDeadline"]`). Bounds are inclusive ISO dates.
736
+ *
737
+ * A notice with NO deadline is EXCLUDED from a windowed query (it cannot be
738
+ * proven inside the window) — the caller discloses this. When neither bound is
739
+ * given, the page is returned unchanged (no window requested).
740
+ */
741
+ export function applyResponseDeadlineWindow<
742
+ T extends { responseDeadline?: string | null },
743
+ >(notices: T[], from?: string, to?: string): T[] {
744
+ if (!from && !to) return notices;
745
+ const fromMs = from ? Date.parse(from) : null;
746
+ const toMs = to ? Date.parse(to) : null;
747
+ return notices.filter((n) => {
748
+ if (!n.responseDeadline) return false; // no deadline ⇒ not provably in-window
749
+ const ms = Date.parse(n.responseDeadline);
750
+ if (Number.isNaN(ms)) return false;
751
+ if (fromMs !== null && !Number.isNaN(fromMs) && ms < fromMs) return false;
752
+ if (toMs !== null && !Number.isNaN(toMs) && ms > toMs) return false;
753
+ return true;
754
+ });
755
+ }
756
+
475
757
  // ─── Helpers ────────────────────────────────────────────────────
476
758
 
477
759
  function formatSamDate(date: Date): string {
@@ -14,7 +14,11 @@
14
14
  *
15
15
  * Canonical home: https://github.com/cliwant/mcp-sam-gov
16
16
  */
17
- export { SamGovClient } from "./client.js";
17
+ export {
18
+ SamGovClient,
19
+ daysUntilResponse,
20
+ applyResponseDeadlineWindow,
21
+ } from "./client.js";
18
22
  export type {
19
23
  SamOpportunity,
20
24
  SamSearchFilters,
@@ -75,6 +75,17 @@ export type SamOpportunity = {
75
75
  placeOfPerformance?: SamLocation | null;
76
76
  uiLink?: string;
77
77
  resourceLinks?: string[] | null;
78
+ /**
79
+ * Which enrichment sub-fetches DEGRADED on an outage (subset of
80
+ * ["attachments", "organization"]). Set ONLY when a sub-fetch's HTTP call
81
+ * failed (non-200 / network / timeout) — the corresponding field
82
+ * (`resourceLinks` / `fullParentPathName`) is then empty but UNKNOWN, NOT
83
+ * confirmed-absent. Additive + optional: a healthy notice (incl. a genuine
84
+ * 200-empty attachment list or empty org path) NEVER sets it. Tool wrappers
85
+ * read it to emit an honest `_meta.degraded` + a disclosing note instead of
86
+ * a silent "no attachments"/"no organization" lie.
87
+ */
88
+ enrichmentDegraded?: string[];
78
89
  };
79
90
 
80
91
  export type SamSearchFilters = {
@@ -100,6 +111,17 @@ export type SamSearchResult = {
100
111
  limit: number;
101
112
  offset: number;
102
113
  opportunitiesData: SamOpportunity[];
114
+ /**
115
+ * Set ONLY when the search could not run at all (every access tier threw:
116
+ * HAL down / network / 5xx-after-retry). It disambiguates a TOTAL OUTAGE
117
+ * from a GENUINE zero — both otherwise present as
118
+ * `{ totalRecords: 0, opportunitiesData: [] }`. Additive + optional:
119
+ * existing readers that ignore it are unaffected; a healthy search (incl.
120
+ * a real 0 or an empty page past the end) NEVER sets it. Tool wrappers use
121
+ * it to emit an honest `_meta` (`complete:false`, `totalAvailable:null`,
122
+ * a disclosing note) instead of the silent "0 notices, complete" lie.
123
+ */
124
+ degraded?: { reason: string };
103
125
  };
104
126
 
105
127
  export type EntitySearchResult = {