@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.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/src/sam-gov/client.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 {
|
package/src/sam-gov/index.ts
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Canonical home: https://github.com/cliwant/mcp-sam-gov
|
|
16
16
|
*/
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
SamGovClient,
|
|
19
|
+
daysUntilResponse,
|
|
20
|
+
applyResponseDeadlineWindow,
|
|
21
|
+
} from "./client.js";
|
|
18
22
|
export type {
|
|
19
23
|
SamOpportunity,
|
|
20
24
|
SamSearchFilters,
|
package/src/sam-gov/types.ts
CHANGED
|
@@ -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 = {
|