@cliwant/mcp-sam-gov 0.2.1
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 -0
- package/README.ja.md +184 -0
- package/README.ko.md +184 -0
- package/README.md +397 -0
- package/dist/ecfr.d.ts +44 -0
- package/dist/ecfr.d.ts.map +1 -0
- package/dist/ecfr.js +86 -0
- package/dist/ecfr.js.map +1 -0
- package/dist/federal-register.d.ts +82 -0
- package/dist/federal-register.d.ts.map +1 -0
- package/dist/federal-register.js +117 -0
- package/dist/federal-register.js.map +1 -0
- package/dist/grants.d.ts +63 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +93 -0
- package/dist/grants.js.map +1 -0
- package/dist/sam-gov/client.d.ts +69 -0
- package/dist/sam-gov/client.d.ts.map +1 -0
- package/dist/sam-gov/client.js +401 -0
- package/dist/sam-gov/client.js.map +1 -0
- package/dist/sam-gov/index.d.ts +19 -0
- package/dist/sam-gov/index.d.ts.map +1 -0
- package/dist/sam-gov/index.js +18 -0
- package/dist/sam-gov/index.js.map +1 -0
- package/dist/sam-gov/types.d.ts +109 -0
- package/dist/sam-gov/types.d.ts.map +1 -0
- package/dist/sam-gov/types.js +7 -0
- package/dist/sam-gov/types.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +685 -0
- package/dist/server.js.map +1 -0
- package/dist/usaspending.d.ts +369 -0
- package/dist/usaspending.d.ts.map +1 -0
- package/dist/usaspending.js +555 -0
- package/dist/usaspending.js.map +1 -0
- package/package.json +88 -0
- package/src/ecfr.ts +127 -0
- package/src/federal-register.ts +191 -0
- package/src/grants.ts +155 -0
- package/src/sam-gov/client.ts +492 -0
- package/src/sam-gov/index.ts +28 -0
- package/src/sam-gov/types.ts +130 -0
- package/src/server.ts +856 -0
- package/src/usaspending.ts +925 -0
|
@@ -0,0 +1,492 @@
|
|
|
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 type {
|
|
17
|
+
EntitySearchResult,
|
|
18
|
+
SamGovClientOptions,
|
|
19
|
+
SamOpportunity,
|
|
20
|
+
SamSearchFilters,
|
|
21
|
+
SamSearchResult,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
const PROD_BASE = "https://api.sam.gov/opportunities/v2/search";
|
|
25
|
+
const ENTITY_BASE = "https://api.sam.gov/entity-information/v3/entities";
|
|
26
|
+
const PUBLIC_BASE = "https://sam.gov/api/prod";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_USER_AGENT =
|
|
29
|
+
"Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
|
|
30
|
+
|
|
31
|
+
export class SamGovClient {
|
|
32
|
+
private readonly apiKey?: string;
|
|
33
|
+
private readonly userAgent: string;
|
|
34
|
+
private readonly fetchImpl: typeof fetch;
|
|
35
|
+
private readonly logger: { warn?: (msg: string, err?: unknown) => void };
|
|
36
|
+
|
|
37
|
+
constructor(options: SamGovClientOptions = {}) {
|
|
38
|
+
this.apiKey = options.apiKey?.trim();
|
|
39
|
+
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
40
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
41
|
+
this.logger = options.logger ?? {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Search SAM.gov opportunities.
|
|
46
|
+
*
|
|
47
|
+
* Three-tier fallback:
|
|
48
|
+
* 1. Authenticated v2 search (if `apiKey` configured)
|
|
49
|
+
* 2. Keyless HAL search
|
|
50
|
+
* 3. Empty result (caller can decide how to surface "no data")
|
|
51
|
+
*/
|
|
52
|
+
async searchOpportunities(
|
|
53
|
+
filters: SamSearchFilters,
|
|
54
|
+
): Promise<SamSearchResult> {
|
|
55
|
+
if (this.apiKey) {
|
|
56
|
+
try {
|
|
57
|
+
const url = this.buildAuthSearchUrl(filters);
|
|
58
|
+
const r = await this.fetchImpl(url, {
|
|
59
|
+
headers: { Accept: "application/json", "User-Agent": this.userAgent },
|
|
60
|
+
});
|
|
61
|
+
if (r.ok) return (await r.json()) as SamSearchResult;
|
|
62
|
+
this.warn(`auth search ${r.status}; trying public`);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
this.warn("auth search failed, trying public", err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const r = await this.searchPublic(filters);
|
|
69
|
+
if (r.opportunitiesData.length > 0) return r;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.warn("public search failed", err);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
totalRecords: 0,
|
|
75
|
+
limit: filters.limit ?? 25,
|
|
76
|
+
offset: filters.offset ?? 0,
|
|
77
|
+
opportunitiesData: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a single opportunity by `noticeId` (32-char hex).
|
|
83
|
+
*
|
|
84
|
+
* Three-tier fallback:
|
|
85
|
+
* 1. Authenticated v2 search filtered by noticeId (if key)
|
|
86
|
+
* 2. Keyless detail endpoint + resources + org enrichment
|
|
87
|
+
* 3. null
|
|
88
|
+
*/
|
|
89
|
+
async getOpportunity(noticeId: string): Promise<SamOpportunity | null> {
|
|
90
|
+
if (this.apiKey) {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(PROD_BASE);
|
|
93
|
+
const range = defaultPostedRange();
|
|
94
|
+
const yearAgo = new Date();
|
|
95
|
+
yearAgo.setUTCFullYear(yearAgo.getUTCFullYear() - 1);
|
|
96
|
+
url.searchParams.set("api_key", this.apiKey);
|
|
97
|
+
url.searchParams.set("postedFrom", formatSamDate(yearAgo));
|
|
98
|
+
url.searchParams.set("postedTo", range.postedTo);
|
|
99
|
+
url.searchParams.set("noticeid", noticeId);
|
|
100
|
+
url.searchParams.set("limit", "1");
|
|
101
|
+
const r = await this.fetchImpl(url.toString(), {
|
|
102
|
+
headers: { Accept: "application/json", "User-Agent": this.userAgent },
|
|
103
|
+
});
|
|
104
|
+
if (r.ok) {
|
|
105
|
+
const json = (await r.json()) as SamSearchResult;
|
|
106
|
+
const hit = json.opportunitiesData?.[0];
|
|
107
|
+
if (hit) {
|
|
108
|
+
if (!hit.resourceLinks || hit.resourceLinks.length === 0) {
|
|
109
|
+
hit.resourceLinks = await this.getPublicResourceLinks(noticeId);
|
|
110
|
+
}
|
|
111
|
+
return hit;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
this.warn("auth getOpportunity failed, trying public", err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return await this.getOpportunityPublic(noticeId);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
this.warn("public getOpportunity failed", err);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetch the full description body for an opportunity.
|
|
128
|
+
*
|
|
129
|
+
* Handles three input shapes:
|
|
130
|
+
* 1. Already-extracted text (no `http://`) — pass-through
|
|
131
|
+
* 2. `api.sam.gov/.../v1/api/getDescription/...` — append `?api_key=`
|
|
132
|
+
* 3. Public sam.gov URL — HAL headers, no key
|
|
133
|
+
*/
|
|
134
|
+
async fetchOpportunityDescription(input: string): Promise<string> {
|
|
135
|
+
if (!/^https?:\/\//i.test(input)) {
|
|
136
|
+
return input.trim() || "Description not available.";
|
|
137
|
+
}
|
|
138
|
+
const isApi = /(^|\/\/)api\.sam\.gov\b/i.test(input);
|
|
139
|
+
const isPublic =
|
|
140
|
+
/(^|\/\/)sam\.gov\b/i.test(input) && !isApi;
|
|
141
|
+
let finalUrl = input;
|
|
142
|
+
let headers: HeadersInit = {
|
|
143
|
+
Accept: "text/html, text/plain, */*",
|
|
144
|
+
"User-Agent": this.userAgent,
|
|
145
|
+
};
|
|
146
|
+
if (isApi && this.apiKey) {
|
|
147
|
+
finalUrl = `${input}${input.includes("?") ? "&" : "?"}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
148
|
+
} else if (isPublic) {
|
|
149
|
+
headers = {
|
|
150
|
+
Accept: "text/html, text/plain, application/hal+json, */*",
|
|
151
|
+
"User-Agent": this.userAgent,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const r = await this.fetchImpl(finalUrl, { headers });
|
|
156
|
+
if (!r.ok) return "Description not available.";
|
|
157
|
+
const ct = r.headers.get("content-type") ?? "";
|
|
158
|
+
if (ct.includes("application/json") || ct.includes("application/hal+json")) {
|
|
159
|
+
const json = (await r.json()) as {
|
|
160
|
+
body?: string;
|
|
161
|
+
description?: string;
|
|
162
|
+
data?: { body?: string };
|
|
163
|
+
};
|
|
164
|
+
return (
|
|
165
|
+
(json.body ?? json.data?.body ?? json.description ?? "").trim() ||
|
|
166
|
+
"Description not available."
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const text = await r.text();
|
|
170
|
+
return text
|
|
171
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
172
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
173
|
+
.replace(/<[^>]+>/g, " ")
|
|
174
|
+
.replace(/ /g, " ")
|
|
175
|
+
.replace(/&/g, "&")
|
|
176
|
+
.replace(/</g, "<")
|
|
177
|
+
.replace(/>/g, ">")
|
|
178
|
+
.replace(/'/g, "'")
|
|
179
|
+
.replace(/"/g, '"')
|
|
180
|
+
.replace(/\s+/g, " ")
|
|
181
|
+
.trim();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.warn("fetchOpportunityDescription failed", err);
|
|
184
|
+
return "Description not available.";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Look up registered SAM.gov entities by legal business name.
|
|
190
|
+
* Requires an API key (the entity registration API has no public
|
|
191
|
+
* keyless mirror — it's the one place BYOK is genuinely needed).
|
|
192
|
+
*/
|
|
193
|
+
async searchEntities(query: string): Promise<EntitySearchResult> {
|
|
194
|
+
if (!this.apiKey) return { entities: [], totalRecords: 0 };
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(ENTITY_BASE);
|
|
197
|
+
url.searchParams.set("api_key", this.apiKey);
|
|
198
|
+
url.searchParams.set("legalBusinessName", query);
|
|
199
|
+
url.searchParams.set("registrationStatus", "A");
|
|
200
|
+
const r = await this.fetchImpl(url.toString(), {
|
|
201
|
+
headers: { Accept: "application/json", "User-Agent": this.userAgent },
|
|
202
|
+
});
|
|
203
|
+
if (!r.ok) throw new Error(`Entity search ${r.status}`);
|
|
204
|
+
type RawEntity = {
|
|
205
|
+
entityRegistration?: {
|
|
206
|
+
ueiSAM?: string;
|
|
207
|
+
legalBusinessName?: string;
|
|
208
|
+
cageCode?: string;
|
|
209
|
+
registrationStatus?: string;
|
|
210
|
+
};
|
|
211
|
+
coreData?: {
|
|
212
|
+
physicalAddress?: { city?: string; stateOrProvinceCode?: string };
|
|
213
|
+
};
|
|
214
|
+
assertions?: {
|
|
215
|
+
goodsAndServices?: { naicsList?: { naicsCode?: string }[] };
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
const json = (await r.json()) as {
|
|
219
|
+
entityData?: RawEntity[];
|
|
220
|
+
totalRecords?: number;
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
entities: (json.entityData ?? []).map((e) => ({
|
|
224
|
+
ueiSAM: e.entityRegistration?.ueiSAM ?? "",
|
|
225
|
+
legalBusinessName: e.entityRegistration?.legalBusinessName ?? "",
|
|
226
|
+
cageCode: e.entityRegistration?.cageCode,
|
|
227
|
+
physicalAddress: e.coreData?.physicalAddress,
|
|
228
|
+
naics:
|
|
229
|
+
e.assertions?.goodsAndServices?.naicsList?.map(
|
|
230
|
+
(n) => n.naicsCode ?? "",
|
|
231
|
+
) ?? [],
|
|
232
|
+
activeRegistration:
|
|
233
|
+
e.entityRegistration?.registrationStatus === "Active",
|
|
234
|
+
})),
|
|
235
|
+
totalRecords: json.totalRecords ?? 0,
|
|
236
|
+
};
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this.warn("entity search failed", err);
|
|
239
|
+
return { entities: [], totalRecords: 0 };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build the keyless download URL for an attachment, given the
|
|
245
|
+
* resourceId from getPublicResourceLinks(). Returns a 303 redirect
|
|
246
|
+
* to a signed S3 URL when fetched. Useful for embedding viewers.
|
|
247
|
+
*/
|
|
248
|
+
publicDownloadUrl(resourceId: string): string {
|
|
249
|
+
return `${PUBLIC_BASE}/opps/v3/opportunities/resources/files/${encodeURIComponent(resourceId)}/download`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Internal: keyless layer ──────────────────────────────────
|
|
253
|
+
|
|
254
|
+
private buildAuthSearchUrl(filters: SamSearchFilters): string {
|
|
255
|
+
const url = new URL(PROD_BASE);
|
|
256
|
+
const range =
|
|
257
|
+
filters.postedFrom && filters.postedTo
|
|
258
|
+
? { postedFrom: filters.postedFrom, postedTo: filters.postedTo }
|
|
259
|
+
: defaultPostedRange();
|
|
260
|
+
url.searchParams.set("api_key", this.apiKey!);
|
|
261
|
+
url.searchParams.set("postedFrom", range.postedFrom);
|
|
262
|
+
url.searchParams.set("postedTo", range.postedTo);
|
|
263
|
+
url.searchParams.set("limit", String(filters.limit ?? 25));
|
|
264
|
+
url.searchParams.set("offset", String(filters.offset ?? 0));
|
|
265
|
+
if (filters.query) url.searchParams.set("title", filters.query);
|
|
266
|
+
if (filters.ptype?.length) url.searchParams.set("ptype", filters.ptype.join(","));
|
|
267
|
+
if (filters.ncode) url.searchParams.set("ncode", filters.ncode);
|
|
268
|
+
if (filters.setAside?.length)
|
|
269
|
+
url.searchParams.set("typeOfSetAside", filters.setAside.join(","));
|
|
270
|
+
if (filters.organizationName)
|
|
271
|
+
url.searchParams.set("organizationName", filters.organizationName);
|
|
272
|
+
if (filters.state) url.searchParams.set("state", filters.state);
|
|
273
|
+
if (filters.zip) url.searchParams.set("zip", filters.zip);
|
|
274
|
+
if (filters.responseDeadlineFrom)
|
|
275
|
+
url.searchParams.set("rdlfrom", filters.responseDeadlineFrom);
|
|
276
|
+
if (filters.responseDeadlineTo)
|
|
277
|
+
url.searchParams.set("rdlto", filters.responseDeadlineTo);
|
|
278
|
+
return url.toString();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async searchPublic(
|
|
282
|
+
filters: SamSearchFilters,
|
|
283
|
+
): Promise<SamSearchResult> {
|
|
284
|
+
const url = new URL(`${PUBLIC_BASE}/sgs/v1/search/`);
|
|
285
|
+
url.searchParams.set("index", "opp");
|
|
286
|
+
url.searchParams.set("page", "0");
|
|
287
|
+
url.searchParams.set("mode", "search");
|
|
288
|
+
url.searchParams.set("sort", "-modifiedDate");
|
|
289
|
+
url.searchParams.set("size", String(filters.limit ?? 25));
|
|
290
|
+
url.searchParams.set("is_active", "true");
|
|
291
|
+
if (filters.query) url.searchParams.set("q", filters.query);
|
|
292
|
+
if (filters.ncode) url.searchParams.append("naics_code", filters.ncode);
|
|
293
|
+
if (filters.organizationName)
|
|
294
|
+
url.searchParams.set("organization_name", filters.organizationName);
|
|
295
|
+
if (filters.setAside?.length)
|
|
296
|
+
for (const sa of filters.setAside) url.searchParams.append("set_aside", sa);
|
|
297
|
+
if (filters.state)
|
|
298
|
+
url.searchParams.set("place_of_performance_state", filters.state);
|
|
299
|
+
|
|
300
|
+
const r = await this.fetchImpl(url.toString(), {
|
|
301
|
+
headers: this.publicHeaders(),
|
|
302
|
+
});
|
|
303
|
+
if (!r.ok) throw new Error(`SAM.gov public search ${r.status}`);
|
|
304
|
+
const json = (await r.json()) as {
|
|
305
|
+
page?: { totalElements?: number };
|
|
306
|
+
_embedded?: {
|
|
307
|
+
results?: {
|
|
308
|
+
_id?: string;
|
|
309
|
+
title?: string;
|
|
310
|
+
solicitationNumber?: string;
|
|
311
|
+
organizationHierarchy?: { name?: string; level?: number }[];
|
|
312
|
+
type?: { code?: string; value?: string };
|
|
313
|
+
publishDate?: string;
|
|
314
|
+
responseDate?: string;
|
|
315
|
+
isActive?: boolean;
|
|
316
|
+
descriptions?: { content?: string }[];
|
|
317
|
+
}[];
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
const totalRecords = json.page?.totalElements ?? 0;
|
|
321
|
+
const results = json._embedded?.results ?? [];
|
|
322
|
+
const data: SamOpportunity[] = results.map((r) => {
|
|
323
|
+
const hierarchy = (r.organizationHierarchy ?? [])
|
|
324
|
+
.filter((h) => h.name)
|
|
325
|
+
.sort((a, b) => (a.level ?? 0) - (b.level ?? 0))
|
|
326
|
+
.map((h) => h.name as string);
|
|
327
|
+
return {
|
|
328
|
+
noticeId: r._id ?? "",
|
|
329
|
+
title: r.title ?? "",
|
|
330
|
+
solicitationNumber: r.solicitationNumber,
|
|
331
|
+
fullParentPathName: hierarchy.join("."),
|
|
332
|
+
postedDate: r.publishDate,
|
|
333
|
+
type: r.type?.value,
|
|
334
|
+
baseType: r.type?.code,
|
|
335
|
+
typeOfSetAsideDescription: null,
|
|
336
|
+
typeOfSetAside: null,
|
|
337
|
+
responseDeadLine: r.responseDate ?? null,
|
|
338
|
+
naicsCode: null,
|
|
339
|
+
active: r.isActive === false ? "No" : "Yes",
|
|
340
|
+
placeOfPerformance: null,
|
|
341
|
+
description: r.descriptions?.[0]?.content,
|
|
342
|
+
uiLink: r._id ? `https://sam.gov/opp/${r._id}/view` : undefined,
|
|
343
|
+
resourceLinks: [],
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
return {
|
|
347
|
+
totalRecords,
|
|
348
|
+
limit: filters.limit ?? 25,
|
|
349
|
+
offset: filters.offset ?? 0,
|
|
350
|
+
opportunitiesData: data,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async getOpportunityPublic(
|
|
355
|
+
noticeId: string,
|
|
356
|
+
): Promise<SamOpportunity | null> {
|
|
357
|
+
const url = `${PUBLIC_BASE}/opps/v2/opportunities/${encodeURIComponent(noticeId)}`;
|
|
358
|
+
const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
|
|
359
|
+
if (!r.ok) return null;
|
|
360
|
+
type DetailResp = {
|
|
361
|
+
data2?: {
|
|
362
|
+
title?: string;
|
|
363
|
+
type?: string;
|
|
364
|
+
organizationId?: string;
|
|
365
|
+
classificationCode?: string;
|
|
366
|
+
postedDate?: string;
|
|
367
|
+
archived?: boolean;
|
|
368
|
+
archive?: { date?: string; type?: string };
|
|
369
|
+
naics?: { code?: string[] }[];
|
|
370
|
+
solicitationNumber?: string;
|
|
371
|
+
solicitation?: { setAside?: string; deadlines?: { response?: string } };
|
|
372
|
+
placeOfPerformance?: SamOpportunity["placeOfPerformance"];
|
|
373
|
+
pointOfContact?: {
|
|
374
|
+
type?: string;
|
|
375
|
+
email?: string;
|
|
376
|
+
phone?: string;
|
|
377
|
+
title?: string;
|
|
378
|
+
fullName?: string;
|
|
379
|
+
}[];
|
|
380
|
+
};
|
|
381
|
+
description?: { body?: string }[];
|
|
382
|
+
};
|
|
383
|
+
const detail = (await r.json()) as DetailResp;
|
|
384
|
+
const d = detail.data2 ?? {};
|
|
385
|
+
if (!d.title) return null;
|
|
386
|
+
const [resourceLinks, fullParentPathName] = await Promise.all([
|
|
387
|
+
this.getPublicResourceLinks(noticeId),
|
|
388
|
+
d.organizationId
|
|
389
|
+
? this.getPublicOrgName(d.organizationId)
|
|
390
|
+
: Promise.resolve(""),
|
|
391
|
+
]);
|
|
392
|
+
return {
|
|
393
|
+
noticeId,
|
|
394
|
+
title: d.title,
|
|
395
|
+
solicitationNumber: d.solicitationNumber,
|
|
396
|
+
fullParentPathName,
|
|
397
|
+
postedDate: d.postedDate,
|
|
398
|
+
type: d.type,
|
|
399
|
+
baseType: d.type,
|
|
400
|
+
archiveDate: d.archive?.date,
|
|
401
|
+
archiveType: d.archive?.type,
|
|
402
|
+
typeOfSetAsideDescription: d.solicitation?.setAside ?? null,
|
|
403
|
+
typeOfSetAside: d.solicitation?.setAside ?? null,
|
|
404
|
+
responseDeadLine: d.solicitation?.deadlines?.response ?? null,
|
|
405
|
+
naicsCode: d.naics?.[0]?.code?.[0] ?? null,
|
|
406
|
+
classificationCode: d.classificationCode,
|
|
407
|
+
active: d.archived ? "No" : "Yes",
|
|
408
|
+
placeOfPerformance: d.placeOfPerformance ?? null,
|
|
409
|
+
description: detail.description?.[0]?.body,
|
|
410
|
+
pointOfContact: d.pointOfContact ?? [],
|
|
411
|
+
uiLink: `https://sam.gov/opp/${noticeId}/view`,
|
|
412
|
+
resourceLinks,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private async getPublicResourceLinks(noticeId: string): Promise<string[]> {
|
|
417
|
+
try {
|
|
418
|
+
const url = `${PUBLIC_BASE}/opps/v3/opportunities/${encodeURIComponent(noticeId)}/resources`;
|
|
419
|
+
const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
|
|
420
|
+
if (!r.ok) return [];
|
|
421
|
+
type Resp = {
|
|
422
|
+
_embedded?: {
|
|
423
|
+
opportunityAttachmentList?: {
|
|
424
|
+
attachments?: { resourceId?: string; name?: string }[];
|
|
425
|
+
}[];
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
const json = (await r.json()) as Resp;
|
|
429
|
+
const attachments =
|
|
430
|
+
json._embedded?.opportunityAttachmentList?.[0]?.attachments ?? [];
|
|
431
|
+
return attachments
|
|
432
|
+
.filter((a) => a.resourceId)
|
|
433
|
+
.map((a) => this.publicDownloadUrl(a.resourceId!));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
this.warn("getPublicResourceLinks failed", err);
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async getPublicOrgName(orgId: string): Promise<string> {
|
|
441
|
+
try {
|
|
442
|
+
const url = `${PUBLIC_BASE}/federalorganizations/v1/organizations/${encodeURIComponent(orgId)}`;
|
|
443
|
+
const r = await this.fetchImpl(url, { headers: this.publicHeaders() });
|
|
444
|
+
if (!r.ok) return "";
|
|
445
|
+
type Resp = {
|
|
446
|
+
_embedded?: {
|
|
447
|
+
org?: {
|
|
448
|
+
fullParentPathName?: string;
|
|
449
|
+
agencyName?: string;
|
|
450
|
+
name?: string;
|
|
451
|
+
};
|
|
452
|
+
}[];
|
|
453
|
+
};
|
|
454
|
+
const json = (await r.json()) as Resp;
|
|
455
|
+
const org = json._embedded?.[0]?.org;
|
|
456
|
+
return org?.fullParentPathName ?? org?.agencyName ?? org?.name ?? "";
|
|
457
|
+
} catch (err) {
|
|
458
|
+
this.warn("getPublicOrgName failed", err);
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private publicHeaders(): HeadersInit {
|
|
464
|
+
return {
|
|
465
|
+
Accept: "application/hal+json",
|
|
466
|
+
"User-Agent": this.userAgent,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private warn(msg: string, err?: unknown) {
|
|
471
|
+
if (this.logger.warn) this.logger.warn(`[mcp-sam-gov/sam-gov] ${msg}`, err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
function formatSamDate(date: Date): string {
|
|
478
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
479
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
480
|
+
const year = date.getUTCFullYear();
|
|
481
|
+
return `${month}/${day}/${year}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function defaultPostedRange(): { postedFrom: string; postedTo: string } {
|
|
485
|
+
const today = new Date();
|
|
486
|
+
const fromDate = new Date(today);
|
|
487
|
+
fromDate.setUTCDate(today.getUTCDate() - 30);
|
|
488
|
+
return {
|
|
489
|
+
postedFrom: formatSamDate(fromDate),
|
|
490
|
+
postedTo: formatSamDate(today),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored copy of @cliwant/mcp-sam-gov/sam-gov.
|
|
3
|
+
*
|
|
4
|
+
* Why vendored: when `@cliwant/mcp-sam-gov` is installed via
|
|
5
|
+
* `npm install -g github:owner/repo`, npm cannot resolve a nested
|
|
6
|
+
* github dep on Windows reliably (it tries to cd into a non-existent
|
|
7
|
+
* directory during the install transaction). Inlining the source
|
|
8
|
+
* eliminates the nested-github-dep issue entirely and keeps install
|
|
9
|
+
* a pure copy.
|
|
10
|
+
*
|
|
11
|
+
* When both packages are published to npm, this file can become a
|
|
12
|
+
* one-line re-export of the upstream package without any consumer
|
|
13
|
+
* changes (the public API surface is identical).
|
|
14
|
+
*
|
|
15
|
+
* Canonical home: https://github.com/cliwant/mcp-sam-gov
|
|
16
|
+
*/
|
|
17
|
+
export { SamGovClient } from "./client.js";
|
|
18
|
+
export type {
|
|
19
|
+
SamOpportunity,
|
|
20
|
+
SamSearchFilters,
|
|
21
|
+
SamSearchResult,
|
|
22
|
+
SamProcurementType,
|
|
23
|
+
SamSetAside,
|
|
24
|
+
SamLocation,
|
|
25
|
+
SamPointOfContact,
|
|
26
|
+
EntitySearchResult,
|
|
27
|
+
SamGovClientOptions,
|
|
28
|
+
} from "./types.js";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the @cliwant/mcp-sam-gov/sam-gov client. Mirrors the shapes
|
|
3
|
+
* returned by SAM.gov's two endpoint layers (authenticated v2 and
|
|
4
|
+
* the keyless public HAL endpoints) under one normalized contract.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SamProcurementType =
|
|
8
|
+
| "u" // Justification
|
|
9
|
+
| "p" // Pre-solicitation
|
|
10
|
+
| "a" // Award Notice
|
|
11
|
+
| "r" // Sources Sought
|
|
12
|
+
| "s" // Special Notice
|
|
13
|
+
| "o" // Solicitation
|
|
14
|
+
| "g" // Sale of Surplus
|
|
15
|
+
| "k" // Combined Synopsis
|
|
16
|
+
| "i"; // Intent to Bundle
|
|
17
|
+
|
|
18
|
+
export type SamSetAside =
|
|
19
|
+
| "SBA"
|
|
20
|
+
| "SBP"
|
|
21
|
+
| "8A"
|
|
22
|
+
| "8AN"
|
|
23
|
+
| "HZC"
|
|
24
|
+
| "HZS"
|
|
25
|
+
| "SDVOSBC"
|
|
26
|
+
| "SDVOSBS"
|
|
27
|
+
| "WOSB"
|
|
28
|
+
| "WOSBSS"
|
|
29
|
+
| "EDWOSB"
|
|
30
|
+
| "EDWOSBSS"
|
|
31
|
+
| "LAS"
|
|
32
|
+
| "IEE"
|
|
33
|
+
| "ISBEE"
|
|
34
|
+
| "BICiv"
|
|
35
|
+
| "VSA"
|
|
36
|
+
| "VSS";
|
|
37
|
+
|
|
38
|
+
export type SamLocation = {
|
|
39
|
+
streetAddress?: string;
|
|
40
|
+
streetAddress2?: string;
|
|
41
|
+
city?: { code?: string; name?: string };
|
|
42
|
+
state?: { code?: string; name?: string };
|
|
43
|
+
zip?: string;
|
|
44
|
+
country?: { code?: string; name?: string };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SamPointOfContact = {
|
|
48
|
+
type?: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
fullName?: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
phone?: string;
|
|
53
|
+
fax?: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type SamOpportunity = {
|
|
57
|
+
noticeId: string;
|
|
58
|
+
title: string;
|
|
59
|
+
solicitationNumber?: string;
|
|
60
|
+
fullParentPathName?: string;
|
|
61
|
+
fullParentPathCode?: string;
|
|
62
|
+
postedDate?: string;
|
|
63
|
+
type?: string;
|
|
64
|
+
baseType?: string;
|
|
65
|
+
archiveType?: string;
|
|
66
|
+
archiveDate?: string | null;
|
|
67
|
+
typeOfSetAsideDescription?: string | null;
|
|
68
|
+
typeOfSetAside?: string | null;
|
|
69
|
+
responseDeadLine?: string | null;
|
|
70
|
+
naicsCode?: string | null;
|
|
71
|
+
classificationCode?: string | null;
|
|
72
|
+
active?: "Yes" | "No";
|
|
73
|
+
pointOfContact?: SamPointOfContact[] | null;
|
|
74
|
+
description?: string;
|
|
75
|
+
placeOfPerformance?: SamLocation | null;
|
|
76
|
+
uiLink?: string;
|
|
77
|
+
resourceLinks?: string[] | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type SamSearchFilters = {
|
|
81
|
+
query?: string;
|
|
82
|
+
/** MM/DD/YYYY (auth endpoint convention). */
|
|
83
|
+
postedFrom?: string;
|
|
84
|
+
postedTo?: string;
|
|
85
|
+
ptype?: SamProcurementType[];
|
|
86
|
+
ncode?: string;
|
|
87
|
+
setAside?: SamSetAside[];
|
|
88
|
+
organizationName?: string;
|
|
89
|
+
state?: string;
|
|
90
|
+
zip?: string;
|
|
91
|
+
responseDeadlineFrom?: string;
|
|
92
|
+
responseDeadlineTo?: string;
|
|
93
|
+
active?: boolean;
|
|
94
|
+
limit?: number;
|
|
95
|
+
offset?: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type SamSearchResult = {
|
|
99
|
+
totalRecords: number;
|
|
100
|
+
limit: number;
|
|
101
|
+
offset: number;
|
|
102
|
+
opportunitiesData: SamOpportunity[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type EntitySearchResult = {
|
|
106
|
+
entities: Array<{
|
|
107
|
+
ueiSAM: string;
|
|
108
|
+
legalBusinessName: string;
|
|
109
|
+
cageCode?: string;
|
|
110
|
+
physicalAddress?: { city?: string; stateOrProvinceCode?: string };
|
|
111
|
+
naics?: string[];
|
|
112
|
+
setAsides?: string[];
|
|
113
|
+
activeRegistration?: boolean;
|
|
114
|
+
}>;
|
|
115
|
+
totalRecords: number;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type SamGovClientOptions = {
|
|
119
|
+
/** SAM.gov public API key. Optional — keyless public endpoints
|
|
120
|
+
* cover ~95% of opportunity discovery without one. Set when you
|
|
121
|
+
* need the higher rate limit + the full historical archive. */
|
|
122
|
+
apiKey?: string;
|
|
123
|
+
/** Override the User-Agent the client sends to SAM.gov. */
|
|
124
|
+
userAgent?: string;
|
|
125
|
+
/** Override the underlying fetch (e.g. with `node-fetch` polyfill or
|
|
126
|
+
* a wrapper for caching/retries). Defaults to global `fetch`. */
|
|
127
|
+
fetch?: typeof fetch;
|
|
128
|
+
/** Optional logger (defaults to a noop). */
|
|
129
|
+
logger?: { warn?: (msg: string, err?: unknown) => void };
|
|
130
|
+
};
|