@cliwant/mcp-sam-gov 1.4.0 → 1.6.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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +240 -231
  3. package/README.ko.md +240 -231
  4. package/README.md +725 -706
  5. package/dist/cbp-border.d.ts +51 -0
  6. package/dist/cbp-border.d.ts.map +1 -0
  7. package/dist/cbp-border.js +123 -0
  8. package/dist/cbp-border.js.map +1 -0
  9. package/dist/datagov-catalog.d.ts.map +1 -1
  10. package/dist/datagov-catalog.js +16 -2
  11. package/dist/datagov-catalog.js.map +1 -1
  12. package/dist/ecfr.d.ts +2 -2
  13. package/dist/ecfr.d.ts.map +1 -1
  14. package/dist/ecfr.js +24 -10
  15. package/dist/ecfr.js.map +1 -1
  16. package/dist/edgar.d.ts.map +1 -1
  17. package/dist/edgar.js +26 -6
  18. package/dist/edgar.js.map +1 -1
  19. package/dist/epa-envirofacts.d.ts.map +1 -1
  20. package/dist/epa-envirofacts.js +14 -1
  21. package/dist/epa-envirofacts.js.map +1 -1
  22. package/dist/errors.d.ts +10 -0
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +11 -0
  25. package/dist/errors.js.map +1 -1
  26. package/dist/far.d.ts.map +1 -1
  27. package/dist/far.js +3 -1
  28. package/dist/far.js.map +1 -1
  29. package/dist/federal-register.d.ts +2 -2
  30. package/dist/federal-register.d.ts.map +1 -1
  31. package/dist/federal-register.js +26 -10
  32. package/dist/federal-register.js.map +1 -1
  33. package/dist/feedback.d.ts +64 -0
  34. package/dist/feedback.d.ts.map +1 -0
  35. package/dist/feedback.js +131 -0
  36. package/dist/feedback.js.map +1 -0
  37. package/dist/fema.d.ts +36 -0
  38. package/dist/fema.d.ts.map +1 -1
  39. package/dist/fema.js +124 -0
  40. package/dist/fema.js.map +1 -1
  41. package/dist/gov-domains.d.ts +66 -0
  42. package/dist/gov-domains.d.ts.map +1 -0
  43. package/dist/gov-domains.js +211 -0
  44. package/dist/gov-domains.js.map +1 -0
  45. package/dist/nist-controls.d.ts +48 -0
  46. package/dist/nist-controls.d.ts.map +1 -0
  47. package/dist/nist-controls.js +174 -0
  48. package/dist/nist-controls.js.map +1 -0
  49. package/dist/nws-weather.d.ts +57 -0
  50. package/dist/nws-weather.d.ts.map +1 -0
  51. package/dist/nws-weather.js +131 -0
  52. package/dist/nws-weather.js.map +1 -0
  53. package/dist/openfda-drugsfda.d.ts +72 -0
  54. package/dist/openfda-drugsfda.d.ts.map +1 -0
  55. package/dist/openfda-drugsfda.js +230 -0
  56. package/dist/openfda-drugsfda.js.map +1 -0
  57. package/dist/openfda.d.ts.map +1 -1
  58. package/dist/openfda.js +31 -8
  59. package/dist/openfda.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +374 -11
  62. package/dist/server.js.map +1 -1
  63. package/dist/treasury.d.ts +2 -0
  64. package/dist/treasury.d.ts.map +1 -1
  65. package/dist/treasury.js +7 -0
  66. package/dist/treasury.js.map +1 -1
  67. package/dist/usaspending.d.ts +32 -1
  68. package/dist/usaspending.d.ts.map +1 -1
  69. package/dist/usaspending.js +143 -16
  70. package/dist/usaspending.js.map +1 -1
  71. package/package.json +111 -111
  72. package/src/attachments.ts +652 -652
  73. package/src/bea.ts +372 -372
  74. package/src/bls.ts +1943 -1943
  75. package/src/cache.ts +73 -73
  76. package/src/cbp-border.ts +177 -0
  77. package/src/census-economic.ts +431 -431
  78. package/src/census.ts +735 -735
  79. package/src/ckan.ts +495 -495
  80. package/src/clinicaltrials.ts +923 -923
  81. package/src/cms-facility.ts +379 -379
  82. package/src/cms-hospital.ts +344 -344
  83. package/src/cms-supplier.ts +527 -527
  84. package/src/cms-utilization.ts +389 -389
  85. package/src/cms.ts +634 -634
  86. package/src/coerce.ts +47 -47
  87. package/src/courtlistener.ts +465 -465
  88. package/src/cpsc.ts +333 -333
  89. package/src/datagov-catalog.ts +312 -296
  90. package/src/datagov.ts +907 -907
  91. package/src/datagovKey.ts +68 -68
  92. package/src/datasource.ts +721 -721
  93. package/src/disclosure.ts +61 -61
  94. package/src/dol.ts +515 -515
  95. package/src/ecfr.ts +248 -231
  96. package/src/echo.ts +496 -496
  97. package/src/edgar.ts +3046 -3014
  98. package/src/epa-envirofacts.ts +358 -342
  99. package/src/errors.ts +324 -303
  100. package/src/fac.ts +529 -529
  101. package/src/far.ts +1009 -1007
  102. package/src/fdic.ts +2052 -2052
  103. package/src/federal-register.ts +725 -706
  104. package/src/feedback.ts +160 -0
  105. package/src/fema.ts +680 -541
  106. package/src/fpds.ts +620 -620
  107. package/src/fred.ts +464 -464
  108. package/src/gao.ts +744 -744
  109. package/src/gov-domains.ts +237 -0
  110. package/src/govinfo.ts +497 -497
  111. package/src/grants.ts +290 -290
  112. package/src/gsa-csv.ts +992 -992
  113. package/src/gsa-perdiem.ts +361 -361
  114. package/src/integrity.ts +928 -928
  115. package/src/keys.ts +268 -268
  116. package/src/lda.ts +385 -385
  117. package/src/meta.ts +292 -292
  118. package/src/nhtsa.ts +352 -352
  119. package/src/nih.ts +375 -375
  120. package/src/nist-controls.ts +219 -0
  121. package/src/nonprofit.ts +460 -460
  122. package/src/nppes.ts +834 -834
  123. package/src/nsf.ts +706 -706
  124. package/src/nvd.ts +1124 -1124
  125. package/src/nws-weather.ts +167 -0
  126. package/src/ofac.ts +1166 -1166
  127. package/src/openfda-device.ts +356 -356
  128. package/src/openfda-drugsfda.ts +313 -0
  129. package/src/openfda.ts +518 -495
  130. package/src/pricing.ts +1075 -1075
  131. package/src/sam-gov/client.ts +774 -774
  132. package/src/sam-gov/index.ts +32 -32
  133. package/src/sam-gov/types.ts +152 -152
  134. package/src/sba.ts +357 -357
  135. package/src/server.ts +6688 -6297
  136. package/src/snapshot.ts +223 -223
  137. package/src/socrata.ts +532 -532
  138. package/src/treasury.ts +582 -575
  139. package/src/usaspending.ts +2852 -2680
  140. package/src/usitc.ts +420 -420
@@ -1,774 +1,774 @@
1
- /**
2
- * @cliwant/mcp-sam-gov/sam-gov — keyless SAM.gov client.
3
- *
4
- * Two endpoint layers, one normalized contract:
5
- * 1. Authenticated `api.sam.gov/opportunities/v2/search` —
6
- * higher rate limit + full historical archive. Used when the
7
- * caller passes an API key.
8
- * 2. Keyless `sam.gov/api/prod/sgs/v1/search/` (HAL JSON) —
9
- * the same data the SAM.gov website uses to render itself.
10
- * No registration. Reasonable rate.
11
- *
12
- * The client picks layer 1 if an API key is available, falling back
13
- * to layer 2 transparently. Callers don't have to care.
14
- */
15
-
16
- import { ToolErrorCarrier } from "../errors.js";
17
- import type {
18
- EntitySearchResult,
19
- SamGovClientOptions,
20
- SamOpportunity,
21
- SamSearchFilters,
22
- SamSearchResult,
23
- } from "./types.js";
24
-
25
- const PROD_BASE = "https://api.sam.gov/opportunities/v2/search";
26
- const ENTITY_BASE = "https://api.sam.gov/entity-information/v3/entities";
27
- const PUBLIC_BASE = "https://sam.gov/api/prod";
28
-
29
- const DEFAULT_USER_AGENT =
30
- "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
31
-
32
- export class SamGovClient {
33
- private readonly apiKey?: string;
34
- private readonly userAgent: string;
35
- private readonly fetchImpl: typeof fetch;
36
- private readonly logger: { warn?: (msg: string, err?: unknown) => void };
37
-
38
- constructor(options: SamGovClientOptions = {}) {
39
- this.apiKey = options.apiKey?.trim();
40
- this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
41
- this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
42
- this.logger = options.logger ?? {};
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
-
56
- /**
57
- * Search SAM.gov opportunities.
58
- *
59
- * Three-tier fallback:
60
- * 1. Authenticated v2 search (if `apiKey` configured)
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.
65
- */
66
- async searchOpportunities(
67
- filters: SamSearchFilters,
68
- ): Promise<SamSearchResult> {
69
- if (this.apiKey) {
70
- try {
71
- const url = this.buildAuthSearchUrl(filters);
72
- const r = await this.fetchImpl(url, {
73
- headers: { Accept: "application/json", "User-Agent": this.userAgent },
74
- });
75
- if (r.ok) return (await r.json()) as SamSearchResult;
76
- this.warn(`auth search ${r.status}; trying public`);
77
- } catch (err) {
78
- this.warn("auth search failed, trying public", err);
79
- }
80
- }
81
- try {
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);
89
- } catch (err) {
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
- };
101
- }
102
- }
103
-
104
- /**
105
- * Resolve a single opportunity by `noticeId` (32-char hex).
106
- *
107
- * Three-tier fallback:
108
- * 1. Authenticated v2 search filtered by noticeId (if key)
109
- * 2. Keyless detail endpoint + resources + org enrichment
110
- * 3. null
111
- */
112
- async getOpportunity(noticeId: string): Promise<SamOpportunity | null> {
113
- if (this.apiKey) {
114
- try {
115
- const url = new URL(PROD_BASE);
116
- const range = defaultPostedRange();
117
- const yearAgo = new Date();
118
- yearAgo.setUTCFullYear(yearAgo.getUTCFullYear() - 1);
119
- url.searchParams.set("api_key", this.apiKey);
120
- url.searchParams.set("postedFrom", formatSamDate(yearAgo));
121
- url.searchParams.set("postedTo", range.postedTo);
122
- url.searchParams.set("noticeid", noticeId);
123
- url.searchParams.set("limit", "1");
124
- const r = await this.fetchImpl(url.toString(), {
125
- headers: { Accept: "application/json", "User-Agent": this.userAgent },
126
- });
127
- if (r.ok) {
128
- const json = (await r.json()) as SamSearchResult;
129
- const hit = json.opportunitiesData?.[0];
130
- if (hit) {
131
- if (!hit.resourceLinks || hit.resourceLinks.length === 0) {
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
- });
144
- }
145
- return hit;
146
- }
147
- }
148
- } catch (err) {
149
- this.warn("auth getOpportunity failed, trying public", err);
150
- }
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.
157
- try {
158
- return await this.getOpportunityPublic(noticeId);
159
- } catch (err) {
160
- this.warn("public getOpportunity failed", err);
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
- });
171
- }
172
- }
173
-
174
- /**
175
- * Fetch the full description body for an opportunity.
176
- *
177
- * Handles three input shapes:
178
- * 1. Already-extracted text (no `http://`) — pass-through
179
- * 2. `api.sam.gov/.../v1/api/getDescription/...` — append `?api_key=`
180
- * 3. Public sam.gov URL — HAL headers, no key
181
- */
182
- async fetchOpportunityDescription(input: string): Promise<string> {
183
- if (!/^https?:\/\//i.test(input)) {
184
- return input.trim() || "Description not available.";
185
- }
186
- const isApi = /(^|\/\/)api\.sam\.gov\b/i.test(input);
187
- const isPublic =
188
- /(^|\/\/)sam\.gov\b/i.test(input) && !isApi;
189
- let finalUrl = input;
190
- let headers: HeadersInit = {
191
- Accept: "text/html, text/plain, */*",
192
- "User-Agent": this.userAgent,
193
- };
194
- if (isApi && this.apiKey) {
195
- finalUrl = `${input}${input.includes("?") ? "&" : "?"}api_key=${encodeURIComponent(this.apiKey)}`;
196
- } else if (isPublic) {
197
- headers = {
198
- Accept: "text/html, text/plain, application/hal+json, */*",
199
- "User-Agent": this.userAgent,
200
- };
201
- }
202
- try {
203
- const r = await this.fetchImpl(finalUrl, { headers });
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
- }
217
- const ct = r.headers.get("content-type") ?? "";
218
- if (ct.includes("application/json") || ct.includes("application/hal+json")) {
219
- const json = (await r.json()) as {
220
- body?: string;
221
- description?: string;
222
- data?: { body?: string };
223
- };
224
- return (
225
- (json.body ?? json.data?.body ?? json.description ?? "").trim() ||
226
- "Description not available."
227
- );
228
- }
229
- const text = await r.text();
230
- return text
231
- .replace(/<style[\s\S]*?<\/style>/gi, "")
232
- .replace(/<script[\s\S]*?<\/script>/gi, "")
233
- .replace(/<[^>]+>/g, " ")
234
- .replace(/&nbsp;/g, " ")
235
- .replace(/&amp;/g, "&")
236
- .replace(/&lt;/g, "<")
237
- .replace(/&gt;/g, ">")
238
- .replace(/&#39;/g, "'")
239
- .replace(/&quot;/g, '"')
240
- .replace(/\s+/g, " ")
241
- .trim();
242
- } catch (err) {
243
- this.warn("fetchOpportunityDescription failed", err);
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
- });
256
- }
257
- }
258
-
259
- /**
260
- * Look up registered SAM.gov entities by legal business name.
261
- * Requires an API key (the entity registration API has no public
262
- * keyless mirror — it's the one place BYOK is genuinely needed).
263
- */
264
- async searchEntities(query: string): Promise<EntitySearchResult> {
265
- if (!this.apiKey) return { entities: [], totalRecords: 0 };
266
- try {
267
- const url = new URL(ENTITY_BASE);
268
- url.searchParams.set("api_key", this.apiKey);
269
- url.searchParams.set("legalBusinessName", query);
270
- url.searchParams.set("registrationStatus", "A");
271
- const r = await this.fetchImpl(url.toString(), {
272
- headers: { Accept: "application/json", "User-Agent": this.userAgent },
273
- });
274
- if (!r.ok) throw new Error(`Entity search ${r.status}`);
275
- type RawEntity = {
276
- entityRegistration?: {
277
- ueiSAM?: string;
278
- legalBusinessName?: string;
279
- cageCode?: string;
280
- registrationStatus?: string;
281
- };
282
- coreData?: {
283
- physicalAddress?: { city?: string; stateOrProvinceCode?: string };
284
- };
285
- assertions?: {
286
- goodsAndServices?: { naicsList?: { naicsCode?: string }[] };
287
- };
288
- };
289
- const json = (await r.json()) as {
290
- entityData?: RawEntity[];
291
- totalRecords?: number;
292
- };
293
- return {
294
- entities: (json.entityData ?? []).map((e) => ({
295
- ueiSAM: e.entityRegistration?.ueiSAM ?? "",
296
- legalBusinessName: e.entityRegistration?.legalBusinessName ?? "",
297
- cageCode: e.entityRegistration?.cageCode,
298
- physicalAddress: e.coreData?.physicalAddress,
299
- naics:
300
- e.assertions?.goodsAndServices?.naicsList?.map(
301
- (n) => n.naicsCode ?? "",
302
- ) ?? [],
303
- activeRegistration:
304
- e.entityRegistration?.registrationStatus === "Active",
305
- })),
306
- totalRecords: json.totalRecords ?? 0,
307
- };
308
- } catch (err) {
309
- this.warn("entity search failed", err);
310
- return { entities: [], totalRecords: 0 };
311
- }
312
- }
313
-
314
- /**
315
- * Build the keyless download URL for an attachment, given the
316
- * resourceId from getPublicResourceLinks(). Returns a 303 redirect
317
- * to a signed S3 URL when fetched. Useful for embedding viewers.
318
- */
319
- publicDownloadUrl(resourceId: string): string {
320
- return `${PUBLIC_BASE}/opps/v3/opportunities/resources/files/${encodeURIComponent(resourceId)}/download`;
321
- }
322
-
323
- // ─── Internal: keyless layer ──────────────────────────────────
324
-
325
- private buildAuthSearchUrl(filters: SamSearchFilters): string {
326
- const url = new URL(PROD_BASE);
327
- const range =
328
- filters.postedFrom && filters.postedTo
329
- ? { postedFrom: filters.postedFrom, postedTo: filters.postedTo }
330
- : defaultPostedRange();
331
- url.searchParams.set("api_key", this.apiKey!);
332
- url.searchParams.set("postedFrom", range.postedFrom);
333
- url.searchParams.set("postedTo", range.postedTo);
334
- url.searchParams.set("limit", String(filters.limit ?? 25));
335
- url.searchParams.set("offset", String(filters.offset ?? 0));
336
- if (filters.query) url.searchParams.set("title", filters.query);
337
- if (filters.ptype?.length) url.searchParams.set("ptype", filters.ptype.join(","));
338
- if (filters.ncode) url.searchParams.set("ncode", filters.ncode);
339
- if (filters.setAside?.length)
340
- url.searchParams.set("typeOfSetAside", filters.setAside.join(","));
341
- if (filters.organizationName)
342
- url.searchParams.set("organizationName", filters.organizationName);
343
- if (filters.state) url.searchParams.set("state", filters.state);
344
- if (filters.zip) url.searchParams.set("zip", filters.zip);
345
- if (filters.responseDeadlineFrom)
346
- url.searchParams.set("rdlfrom", filters.responseDeadlineFrom);
347
- if (filters.responseDeadlineTo)
348
- url.searchParams.set("rdlto", filters.responseDeadlineTo);
349
- return url.toString();
350
- }
351
-
352
- private async searchPublic(
353
- filters: SamSearchFilters,
354
- ): Promise<SamSearchResult> {
355
- const url = new URL(`${PUBLIC_BASE}/sgs/v1/search/`);
356
- url.searchParams.set("index", "opp");
357
- url.searchParams.set("page", "0");
358
- url.searchParams.set("mode", "search");
359
- url.searchParams.set("sort", "-modifiedDate");
360
- url.searchParams.set("size", String(filters.limit ?? 25));
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.
376
- if (filters.query) url.searchParams.set("q", filters.query);
377
- if (filters.ncode) url.searchParams.append("naics", filters.ncode);
378
- if (filters.organizationName)
379
- url.searchParams.set("organization_name", filters.organizationName);
380
- if (filters.setAside?.length)
381
- for (const sa of filters.setAside) url.searchParams.append("set_aside", sa);
382
- if (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(","));
397
-
398
- const r = await this.fetchImpl(url.toString(), {
399
- headers: this.publicHeaders(),
400
- });
401
- if (!r.ok) throw new Error(`SAM.gov public search ${r.status}`);
402
- const json = (await r.json()) as {
403
- page?: { totalElements?: number };
404
- _embedded?: {
405
- results?: {
406
- _id?: string;
407
- title?: string;
408
- solicitationNumber?: string;
409
- organizationHierarchy?: { name?: string; level?: number }[];
410
- type?: { code?: string; value?: string };
411
- publishDate?: string;
412
- responseDate?: string;
413
- isActive?: boolean;
414
- descriptions?: { content?: string }[];
415
- }[];
416
- };
417
- };
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;
433
- const results = json._embedded?.results ?? [];
434
- const data: SamOpportunity[] = results.map((r) => {
435
- const hierarchy = (r.organizationHierarchy ?? [])
436
- .filter((h) => h.name)
437
- .sort((a, b) => (a.level ?? 0) - (b.level ?? 0))
438
- .map((h) => h.name as string);
439
- return {
440
- noticeId: r._id ?? "",
441
- title: r.title ?? "",
442
- solicitationNumber: r.solicitationNumber,
443
- fullParentPathName: hierarchy.join("."),
444
- postedDate: r.publishDate,
445
- type: r.type?.value,
446
- baseType: r.type?.code,
447
- typeOfSetAsideDescription: null,
448
- typeOfSetAside: null,
449
- responseDeadLine: r.responseDate ?? null,
450
- naicsCode: null,
451
- active: r.isActive === false ? "No" : "Yes",
452
- placeOfPerformance: null,
453
- description: r.descriptions?.[0]?.content,
454
- uiLink: r._id ? `https://sam.gov/opp/${r._id}/view` : undefined,
455
- resourceLinks: [],
456
- };
457
- });
458
- return {
459
- totalRecords,
460
- limit: filters.limit ?? 25,
461
- offset: filters.offset ?? 0,
462
- opportunitiesData: data,
463
- };
464
- }
465
-
466
- private async getOpportunityPublic(
467
- noticeId: string,
468
- ): Promise<SamOpportunity | null> {
469
- const url = `${PUBLIC_BASE}/opps/v2/opportunities/${encodeURIComponent(noticeId)}`;
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
- }
521
- type DetailResp = {
522
- data2?: {
523
- title?: string;
524
- type?: string;
525
- organizationId?: string;
526
- classificationCode?: string;
527
- postedDate?: string;
528
- archived?: boolean;
529
- archive?: { date?: string; type?: string };
530
- naics?: { code?: string[] }[];
531
- solicitationNumber?: string;
532
- solicitation?: { setAside?: string; deadlines?: { response?: string } };
533
- placeOfPerformance?: SamOpportunity["placeOfPerformance"];
534
- pointOfContact?: {
535
- type?: string;
536
- email?: string;
537
- phone?: string;
538
- title?: string;
539
- fullName?: string;
540
- }[];
541
- };
542
- description?: { body?: string }[];
543
- };
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
- }
559
- const d = detail.data2 ?? {};
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[] = [];
583
- const [resourceLinks, fullParentPathName] = await Promise.all([
584
- this.getPublicResourceLinks(noticeId).catch((e) => {
585
- this.warn("resourceLinks enrichment failed", e);
586
- enrichmentDegraded.push("attachments");
587
- return [] as string[];
588
- }),
589
- d.organizationId
590
- ? this.getPublicOrgName(d.organizationId).catch((e) => {
591
- this.warn("orgName enrichment failed", e);
592
- enrichmentDegraded.push("organization");
593
- return "";
594
- })
595
- : Promise.resolve(""),
596
- ]);
597
- return {
598
- noticeId,
599
- title: d.title,
600
- solicitationNumber: d.solicitationNumber,
601
- fullParentPathName,
602
- postedDate: d.postedDate,
603
- type: d.type,
604
- baseType: d.type,
605
- archiveDate: d.archive?.date,
606
- archiveType: d.archive?.type,
607
- typeOfSetAsideDescription: d.solicitation?.setAside ?? null,
608
- typeOfSetAside: d.solicitation?.setAside ?? null,
609
- responseDeadLine: d.solicitation?.deadlines?.response ?? null,
610
- naicsCode: d.naics?.[0]?.code?.[0] ?? null,
611
- classificationCode: d.classificationCode,
612
- active: d.archived ? "No" : "Yes",
613
- placeOfPerformance: d.placeOfPerformance ?? null,
614
- description: detail.description?.[0]?.body,
615
- pointOfContact: d.pointOfContact ?? [],
616
- uiLink: `https://sam.gov/opp/${noticeId}/view`,
617
- resourceLinks,
618
- enrichmentDegraded: enrichmentDegraded.length
619
- ? enrichmentDegraded
620
- : undefined,
621
- };
622
- }
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
- */
638
- private async getPublicResourceLinks(noticeId: string): Promise<string[]> {
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
- }[];
647
- };
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!));
655
- }
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
- */
667
- private async getPublicOrgName(orgId: string): Promise<string> {
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 ?? "";
683
- }
684
-
685
- private publicHeaders(): HeadersInit {
686
- return {
687
- Accept: "application/hal+json",
688
- "User-Agent": this.userAgent,
689
- };
690
- }
691
-
692
- private warn(msg: string, err?: unknown) {
693
- if (this.logger.warn) this.logger.warn(`[mcp-sam-gov/sam-gov] ${msg}`, err);
694
- }
695
- }
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
-
757
- // ─── Helpers ────────────────────────────────────────────────────
758
-
759
- function formatSamDate(date: Date): string {
760
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
761
- const day = String(date.getUTCDate()).padStart(2, "0");
762
- const year = date.getUTCFullYear();
763
- return `${month}/${day}/${year}`;
764
- }
765
-
766
- function defaultPostedRange(): { postedFrom: string; postedTo: string } {
767
- const today = new Date();
768
- const fromDate = new Date(today);
769
- fromDate.setUTCDate(today.getUTCDate() - 30);
770
- return {
771
- postedFrom: formatSamDate(fromDate),
772
- postedTo: formatSamDate(today),
773
- };
774
- }
1
+ /**
2
+ * @cliwant/mcp-sam-gov/sam-gov — keyless SAM.gov client.
3
+ *
4
+ * Two endpoint layers, one normalized contract:
5
+ * 1. Authenticated `api.sam.gov/opportunities/v2/search` —
6
+ * higher rate limit + full historical archive. Used when the
7
+ * caller passes an API key.
8
+ * 2. Keyless `sam.gov/api/prod/sgs/v1/search/` (HAL JSON) —
9
+ * the same data the SAM.gov website uses to render itself.
10
+ * No registration. Reasonable rate.
11
+ *
12
+ * The client picks layer 1 if an API key is available, falling back
13
+ * to layer 2 transparently. Callers don't have to care.
14
+ */
15
+
16
+ import { ToolErrorCarrier } from "../errors.js";
17
+ import type {
18
+ EntitySearchResult,
19
+ SamGovClientOptions,
20
+ SamOpportunity,
21
+ SamSearchFilters,
22
+ SamSearchResult,
23
+ } from "./types.js";
24
+
25
+ const PROD_BASE = "https://api.sam.gov/opportunities/v2/search";
26
+ const ENTITY_BASE = "https://api.sam.gov/entity-information/v3/entities";
27
+ const PUBLIC_BASE = "https://sam.gov/api/prod";
28
+
29
+ const DEFAULT_USER_AGENT =
30
+ "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
31
+
32
+ export class SamGovClient {
33
+ private readonly apiKey?: string;
34
+ private readonly userAgent: string;
35
+ private readonly fetchImpl: typeof fetch;
36
+ private readonly logger: { warn?: (msg: string, err?: unknown) => void };
37
+
38
+ constructor(options: SamGovClientOptions = {}) {
39
+ this.apiKey = options.apiKey?.trim();
40
+ this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
41
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
42
+ this.logger = options.logger ?? {};
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
+
56
+ /**
57
+ * Search SAM.gov opportunities.
58
+ *
59
+ * Three-tier fallback:
60
+ * 1. Authenticated v2 search (if `apiKey` configured)
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.
65
+ */
66
+ async searchOpportunities(
67
+ filters: SamSearchFilters,
68
+ ): Promise<SamSearchResult> {
69
+ if (this.apiKey) {
70
+ try {
71
+ const url = this.buildAuthSearchUrl(filters);
72
+ const r = await this.fetchImpl(url, {
73
+ headers: { Accept: "application/json", "User-Agent": this.userAgent },
74
+ });
75
+ if (r.ok) return (await r.json()) as SamSearchResult;
76
+ this.warn(`auth search ${r.status}; trying public`);
77
+ } catch (err) {
78
+ this.warn("auth search failed, trying public", err);
79
+ }
80
+ }
81
+ try {
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);
89
+ } catch (err) {
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
+ };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Resolve a single opportunity by `noticeId` (32-char hex).
106
+ *
107
+ * Three-tier fallback:
108
+ * 1. Authenticated v2 search filtered by noticeId (if key)
109
+ * 2. Keyless detail endpoint + resources + org enrichment
110
+ * 3. null
111
+ */
112
+ async getOpportunity(noticeId: string): Promise<SamOpportunity | null> {
113
+ if (this.apiKey) {
114
+ try {
115
+ const url = new URL(PROD_BASE);
116
+ const range = defaultPostedRange();
117
+ const yearAgo = new Date();
118
+ yearAgo.setUTCFullYear(yearAgo.getUTCFullYear() - 1);
119
+ url.searchParams.set("api_key", this.apiKey);
120
+ url.searchParams.set("postedFrom", formatSamDate(yearAgo));
121
+ url.searchParams.set("postedTo", range.postedTo);
122
+ url.searchParams.set("noticeid", noticeId);
123
+ url.searchParams.set("limit", "1");
124
+ const r = await this.fetchImpl(url.toString(), {
125
+ headers: { Accept: "application/json", "User-Agent": this.userAgent },
126
+ });
127
+ if (r.ok) {
128
+ const json = (await r.json()) as SamSearchResult;
129
+ const hit = json.opportunitiesData?.[0];
130
+ if (hit) {
131
+ if (!hit.resourceLinks || hit.resourceLinks.length === 0) {
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
+ });
144
+ }
145
+ return hit;
146
+ }
147
+ }
148
+ } catch (err) {
149
+ this.warn("auth getOpportunity failed, trying public", err);
150
+ }
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.
157
+ try {
158
+ return await this.getOpportunityPublic(noticeId);
159
+ } catch (err) {
160
+ this.warn("public getOpportunity failed", err);
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
+ });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Fetch the full description body for an opportunity.
176
+ *
177
+ * Handles three input shapes:
178
+ * 1. Already-extracted text (no `http://`) — pass-through
179
+ * 2. `api.sam.gov/.../v1/api/getDescription/...` — append `?api_key=`
180
+ * 3. Public sam.gov URL — HAL headers, no key
181
+ */
182
+ async fetchOpportunityDescription(input: string): Promise<string> {
183
+ if (!/^https?:\/\//i.test(input)) {
184
+ return input.trim() || "Description not available.";
185
+ }
186
+ const isApi = /(^|\/\/)api\.sam\.gov\b/i.test(input);
187
+ const isPublic =
188
+ /(^|\/\/)sam\.gov\b/i.test(input) && !isApi;
189
+ let finalUrl = input;
190
+ let headers: HeadersInit = {
191
+ Accept: "text/html, text/plain, */*",
192
+ "User-Agent": this.userAgent,
193
+ };
194
+ if (isApi && this.apiKey) {
195
+ finalUrl = `${input}${input.includes("?") ? "&" : "?"}api_key=${encodeURIComponent(this.apiKey)}`;
196
+ } else if (isPublic) {
197
+ headers = {
198
+ Accept: "text/html, text/plain, application/hal+json, */*",
199
+ "User-Agent": this.userAgent,
200
+ };
201
+ }
202
+ try {
203
+ const r = await this.fetchImpl(finalUrl, { headers });
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
+ }
217
+ const ct = r.headers.get("content-type") ?? "";
218
+ if (ct.includes("application/json") || ct.includes("application/hal+json")) {
219
+ const json = (await r.json()) as {
220
+ body?: string;
221
+ description?: string;
222
+ data?: { body?: string };
223
+ };
224
+ return (
225
+ (json.body ?? json.data?.body ?? json.description ?? "").trim() ||
226
+ "Description not available."
227
+ );
228
+ }
229
+ const text = await r.text();
230
+ return text
231
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
232
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
233
+ .replace(/<[^>]+>/g, " ")
234
+ .replace(/&nbsp;/g, " ")
235
+ .replace(/&amp;/g, "&")
236
+ .replace(/&lt;/g, "<")
237
+ .replace(/&gt;/g, ">")
238
+ .replace(/&#39;/g, "'")
239
+ .replace(/&quot;/g, '"')
240
+ .replace(/\s+/g, " ")
241
+ .trim();
242
+ } catch (err) {
243
+ this.warn("fetchOpportunityDescription failed", err);
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
+ });
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Look up registered SAM.gov entities by legal business name.
261
+ * Requires an API key (the entity registration API has no public
262
+ * keyless mirror — it's the one place BYOK is genuinely needed).
263
+ */
264
+ async searchEntities(query: string): Promise<EntitySearchResult> {
265
+ if (!this.apiKey) return { entities: [], totalRecords: 0 };
266
+ try {
267
+ const url = new URL(ENTITY_BASE);
268
+ url.searchParams.set("api_key", this.apiKey);
269
+ url.searchParams.set("legalBusinessName", query);
270
+ url.searchParams.set("registrationStatus", "A");
271
+ const r = await this.fetchImpl(url.toString(), {
272
+ headers: { Accept: "application/json", "User-Agent": this.userAgent },
273
+ });
274
+ if (!r.ok) throw new Error(`Entity search ${r.status}`);
275
+ type RawEntity = {
276
+ entityRegistration?: {
277
+ ueiSAM?: string;
278
+ legalBusinessName?: string;
279
+ cageCode?: string;
280
+ registrationStatus?: string;
281
+ };
282
+ coreData?: {
283
+ physicalAddress?: { city?: string; stateOrProvinceCode?: string };
284
+ };
285
+ assertions?: {
286
+ goodsAndServices?: { naicsList?: { naicsCode?: string }[] };
287
+ };
288
+ };
289
+ const json = (await r.json()) as {
290
+ entityData?: RawEntity[];
291
+ totalRecords?: number;
292
+ };
293
+ return {
294
+ entities: (json.entityData ?? []).map((e) => ({
295
+ ueiSAM: e.entityRegistration?.ueiSAM ?? "",
296
+ legalBusinessName: e.entityRegistration?.legalBusinessName ?? "",
297
+ cageCode: e.entityRegistration?.cageCode,
298
+ physicalAddress: e.coreData?.physicalAddress,
299
+ naics:
300
+ e.assertions?.goodsAndServices?.naicsList?.map(
301
+ (n) => n.naicsCode ?? "",
302
+ ) ?? [],
303
+ activeRegistration:
304
+ e.entityRegistration?.registrationStatus === "Active",
305
+ })),
306
+ totalRecords: json.totalRecords ?? 0,
307
+ };
308
+ } catch (err) {
309
+ this.warn("entity search failed", err);
310
+ return { entities: [], totalRecords: 0 };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Build the keyless download URL for an attachment, given the
316
+ * resourceId from getPublicResourceLinks(). Returns a 303 redirect
317
+ * to a signed S3 URL when fetched. Useful for embedding viewers.
318
+ */
319
+ publicDownloadUrl(resourceId: string): string {
320
+ return `${PUBLIC_BASE}/opps/v3/opportunities/resources/files/${encodeURIComponent(resourceId)}/download`;
321
+ }
322
+
323
+ // ─── Internal: keyless layer ──────────────────────────────────
324
+
325
+ private buildAuthSearchUrl(filters: SamSearchFilters): string {
326
+ const url = new URL(PROD_BASE);
327
+ const range =
328
+ filters.postedFrom && filters.postedTo
329
+ ? { postedFrom: filters.postedFrom, postedTo: filters.postedTo }
330
+ : defaultPostedRange();
331
+ url.searchParams.set("api_key", this.apiKey!);
332
+ url.searchParams.set("postedFrom", range.postedFrom);
333
+ url.searchParams.set("postedTo", range.postedTo);
334
+ url.searchParams.set("limit", String(filters.limit ?? 25));
335
+ url.searchParams.set("offset", String(filters.offset ?? 0));
336
+ if (filters.query) url.searchParams.set("title", filters.query);
337
+ if (filters.ptype?.length) url.searchParams.set("ptype", filters.ptype.join(","));
338
+ if (filters.ncode) url.searchParams.set("ncode", filters.ncode);
339
+ if (filters.setAside?.length)
340
+ url.searchParams.set("typeOfSetAside", filters.setAside.join(","));
341
+ if (filters.organizationName)
342
+ url.searchParams.set("organizationName", filters.organizationName);
343
+ if (filters.state) url.searchParams.set("state", filters.state);
344
+ if (filters.zip) url.searchParams.set("zip", filters.zip);
345
+ if (filters.responseDeadlineFrom)
346
+ url.searchParams.set("rdlfrom", filters.responseDeadlineFrom);
347
+ if (filters.responseDeadlineTo)
348
+ url.searchParams.set("rdlto", filters.responseDeadlineTo);
349
+ return url.toString();
350
+ }
351
+
352
+ private async searchPublic(
353
+ filters: SamSearchFilters,
354
+ ): Promise<SamSearchResult> {
355
+ const url = new URL(`${PUBLIC_BASE}/sgs/v1/search/`);
356
+ url.searchParams.set("index", "opp");
357
+ url.searchParams.set("page", "0");
358
+ url.searchParams.set("mode", "search");
359
+ url.searchParams.set("sort", "-modifiedDate");
360
+ url.searchParams.set("size", String(filters.limit ?? 25));
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.
376
+ if (filters.query) url.searchParams.set("q", filters.query);
377
+ if (filters.ncode) url.searchParams.append("naics", filters.ncode);
378
+ if (filters.organizationName)
379
+ url.searchParams.set("organization_name", filters.organizationName);
380
+ if (filters.setAside?.length)
381
+ for (const sa of filters.setAside) url.searchParams.append("set_aside", sa);
382
+ if (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(","));
397
+
398
+ const r = await this.fetchImpl(url.toString(), {
399
+ headers: this.publicHeaders(),
400
+ });
401
+ if (!r.ok) throw new Error(`SAM.gov public search ${r.status}`);
402
+ const json = (await r.json()) as {
403
+ page?: { totalElements?: number };
404
+ _embedded?: {
405
+ results?: {
406
+ _id?: string;
407
+ title?: string;
408
+ solicitationNumber?: string;
409
+ organizationHierarchy?: { name?: string; level?: number }[];
410
+ type?: { code?: string; value?: string };
411
+ publishDate?: string;
412
+ responseDate?: string;
413
+ isActive?: boolean;
414
+ descriptions?: { content?: string }[];
415
+ }[];
416
+ };
417
+ };
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;
433
+ const results = json._embedded?.results ?? [];
434
+ const data: SamOpportunity[] = results.map((r) => {
435
+ const hierarchy = (r.organizationHierarchy ?? [])
436
+ .filter((h) => h.name)
437
+ .sort((a, b) => (a.level ?? 0) - (b.level ?? 0))
438
+ .map((h) => h.name as string);
439
+ return {
440
+ noticeId: r._id ?? "",
441
+ title: r.title ?? "",
442
+ solicitationNumber: r.solicitationNumber,
443
+ fullParentPathName: hierarchy.join("."),
444
+ postedDate: r.publishDate,
445
+ type: r.type?.value,
446
+ baseType: r.type?.code,
447
+ typeOfSetAsideDescription: null,
448
+ typeOfSetAside: null,
449
+ responseDeadLine: r.responseDate ?? null,
450
+ naicsCode: null,
451
+ active: r.isActive === false ? "No" : "Yes",
452
+ placeOfPerformance: null,
453
+ description: r.descriptions?.[0]?.content,
454
+ uiLink: r._id ? `https://sam.gov/opp/${r._id}/view` : undefined,
455
+ resourceLinks: [],
456
+ };
457
+ });
458
+ return {
459
+ totalRecords,
460
+ limit: filters.limit ?? 25,
461
+ offset: filters.offset ?? 0,
462
+ opportunitiesData: data,
463
+ };
464
+ }
465
+
466
+ private async getOpportunityPublic(
467
+ noticeId: string,
468
+ ): Promise<SamOpportunity | null> {
469
+ const url = `${PUBLIC_BASE}/opps/v2/opportunities/${encodeURIComponent(noticeId)}`;
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
+ }
521
+ type DetailResp = {
522
+ data2?: {
523
+ title?: string;
524
+ type?: string;
525
+ organizationId?: string;
526
+ classificationCode?: string;
527
+ postedDate?: string;
528
+ archived?: boolean;
529
+ archive?: { date?: string; type?: string };
530
+ naics?: { code?: string[] }[];
531
+ solicitationNumber?: string;
532
+ solicitation?: { setAside?: string; deadlines?: { response?: string } };
533
+ placeOfPerformance?: SamOpportunity["placeOfPerformance"];
534
+ pointOfContact?: {
535
+ type?: string;
536
+ email?: string;
537
+ phone?: string;
538
+ title?: string;
539
+ fullName?: string;
540
+ }[];
541
+ };
542
+ description?: { body?: string }[];
543
+ };
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
+ }
559
+ const d = detail.data2 ?? {};
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[] = [];
583
+ const [resourceLinks, fullParentPathName] = await Promise.all([
584
+ this.getPublicResourceLinks(noticeId).catch((e) => {
585
+ this.warn("resourceLinks enrichment failed", e);
586
+ enrichmentDegraded.push("attachments");
587
+ return [] as string[];
588
+ }),
589
+ d.organizationId
590
+ ? this.getPublicOrgName(d.organizationId).catch((e) => {
591
+ this.warn("orgName enrichment failed", e);
592
+ enrichmentDegraded.push("organization");
593
+ return "";
594
+ })
595
+ : Promise.resolve(""),
596
+ ]);
597
+ return {
598
+ noticeId,
599
+ title: d.title,
600
+ solicitationNumber: d.solicitationNumber,
601
+ fullParentPathName,
602
+ postedDate: d.postedDate,
603
+ type: d.type,
604
+ baseType: d.type,
605
+ archiveDate: d.archive?.date,
606
+ archiveType: d.archive?.type,
607
+ typeOfSetAsideDescription: d.solicitation?.setAside ?? null,
608
+ typeOfSetAside: d.solicitation?.setAside ?? null,
609
+ responseDeadLine: d.solicitation?.deadlines?.response ?? null,
610
+ naicsCode: d.naics?.[0]?.code?.[0] ?? null,
611
+ classificationCode: d.classificationCode,
612
+ active: d.archived ? "No" : "Yes",
613
+ placeOfPerformance: d.placeOfPerformance ?? null,
614
+ description: detail.description?.[0]?.body,
615
+ pointOfContact: d.pointOfContact ?? [],
616
+ uiLink: `https://sam.gov/opp/${noticeId}/view`,
617
+ resourceLinks,
618
+ enrichmentDegraded: enrichmentDegraded.length
619
+ ? enrichmentDegraded
620
+ : undefined,
621
+ };
622
+ }
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
+ */
638
+ private async getPublicResourceLinks(noticeId: string): Promise<string[]> {
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
+ }[];
647
+ };
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!));
655
+ }
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
+ */
667
+ private async getPublicOrgName(orgId: string): Promise<string> {
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 ?? "";
683
+ }
684
+
685
+ private publicHeaders(): HeadersInit {
686
+ return {
687
+ Accept: "application/hal+json",
688
+ "User-Agent": this.userAgent,
689
+ };
690
+ }
691
+
692
+ private warn(msg: string, err?: unknown) {
693
+ if (this.logger.warn) this.logger.warn(`[mcp-sam-gov/sam-gov] ${msg}`, err);
694
+ }
695
+ }
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
+
757
+ // ─── Helpers ────────────────────────────────────────────────────
758
+
759
+ function formatSamDate(date: Date): string {
760
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
761
+ const day = String(date.getUTCDate()).padStart(2, "0");
762
+ const year = date.getUTCFullYear();
763
+ return `${month}/${day}/${year}`;
764
+ }
765
+
766
+ function defaultPostedRange(): { postedFrom: string; postedTo: string } {
767
+ const today = new Date();
768
+ const fromDate = new Date(today);
769
+ fromDate.setUTCDate(today.getUTCDate() - 30);
770
+ return {
771
+ postedFrom: formatSamDate(fromDate),
772
+ postedTo: formatSamDate(today),
773
+ };
774
+ }