@cliwant/mcp-sam-gov 1.2.0 → 1.3.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/README.ja.md +12 -7
- package/README.ko.md +12 -7
- package/README.md +28 -10
- package/dist/bea.d.ts +105 -0
- package/dist/bea.d.ts.map +1 -0
- package/dist/bea.js +303 -0
- package/dist/bea.js.map +1 -0
- package/dist/dol.d.ts +118 -0
- package/dist/dol.d.ts.map +1 -0
- package/dist/dol.js +421 -0
- package/dist/dol.js.map +1 -0
- package/dist/keys.d.ts +10 -8
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +36 -8
- package/dist/keys.js.map +1 -1
- package/dist/lda.d.ts +105 -0
- package/dist/lda.d.ts.map +1 -0
- package/dist/lda.js +317 -0
- package/dist/lda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +210 -3
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/bea.ts +372 -0
- package/src/dol.ts +515 -0
- package/src/keys.ts +39 -8
- package/src/lda.ts +385 -0
- package/src/server.ts +234 -3
package/src/dol.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dol.ts — US Department of Labor Data API v4 (apiprod.dol.gov) — the LABOR
|
|
3
|
+
* ENFORCEMENT lane (ADR-0053). WHD wage/hour violations, OSHA inspections, ILAB
|
|
4
|
+
* child/forced-labor reports, MSHA mine safety … — the compliance/enforcement
|
|
5
|
+
* signal no contract/spending/market source carries.
|
|
6
|
+
*
|
|
7
|
+
* TWO tools with a DELIBERATE key split (the honest reflection of the live API):
|
|
8
|
+
* • `dol_list_datasets` (KEYLESS) — GET /v4/datasets. The dataset CATALOG (and
|
|
9
|
+
* /v4/agencies) is keyless; this tool needs NO key. It returns the machine
|
|
10
|
+
* inventory (name / tablename / api_url / agency / …) you page + filter to find
|
|
11
|
+
* an endpoint, then feed its `apiUrl` into dol_get_dataset.
|
|
12
|
+
* • `dol_get_dataset` (KEY-REQUIRED, DOL_API_KEY) — GET
|
|
13
|
+
* /v4/get/{agency}/{endpoint}/json?… . The DATA endpoint has NO keyless tier, so
|
|
14
|
+
* with NO `DOL_API_KEY` this tool THROWS an invalid_input config error BEFORE any
|
|
15
|
+
* fetch (0 network call; the message names DOL_API_KEY + dol.gov/developer).
|
|
16
|
+
* So DOL is the 4th REQUIRED key — but ONLY for the data tool; the catalog tool
|
|
17
|
+
* (and every other tool on the server) stays keyless.
|
|
18
|
+
*
|
|
19
|
+
* ★LIVE-VERIFIED WIRE FACTS (2026-07-15):
|
|
20
|
+
* - Host `apiprod.dol.gov` (AWS API Gateway) + base `/v4`.
|
|
21
|
+
* - /v4/datasets is KEYLESS 200 → `{ datasets:[…], meta:{ current_page, next_page,
|
|
22
|
+
* prev_page, total_pages, total_count } }`. `limit` is the PER-PAGE size (limit=1000
|
|
23
|
+
* returns the whole 42-row catalog in one page, total_pages:1); server-side agency
|
|
24
|
+
* filtering is NOT honored (verified: ?agency=ILAB still returned the full 42) — so
|
|
25
|
+
* agency/query filtering is CLIENT-SIDE over the fetched catalog.
|
|
26
|
+
* - The DATA route is the QUERY-STYLE form `/v4/get/{agency}/{endpoint}/{format}?…`
|
|
27
|
+
* (NOT the path-style `/…/limit/N/offset/O/format/json`): the query-style URL
|
|
28
|
+
* reached the DOL app and returned a proper `401 {"…key…missing…"}` on a bad key,
|
|
29
|
+
* whereas the path-style URL only ever hit the AWS gateway's generic
|
|
30
|
+
* `403 {"message":"Missing Authentication Token"}` (an unmatched route). The DOL
|
|
31
|
+
* API User Guide (dataportal.dol.gov/pdf/dol-api-user-guide.pdf) confirms the
|
|
32
|
+
* `/get/<agency>/<api_url>/<format>?limit=&offset=&filter_object=&…` template and
|
|
33
|
+
* that `<endpoint>` is the dataset's **api_url** (NOT its tablename).
|
|
34
|
+
*
|
|
35
|
+
* ★UNVERIFIED (key-gated — coded DEFENSIVELY, honestly disclosed): the DATA response
|
|
36
|
+
* body shape could not be observed live (every /v4/get call is 401 without a real
|
|
37
|
+
* key, and the User Guide shows no body example). So `dol_get_dataset` accepts EITHER
|
|
38
|
+
* a bare row array `[…]` OR `{ data:[…] }` OR `{ results:[…] }`; a top-level count
|
|
39
|
+
* (`total_count`/`total`/`count`, or `meta.total_count`) is used for totalAvailable
|
|
40
|
+
* ONLY if actually present, else `totalAvailable = null` (an HONEST unknown — never
|
|
41
|
+
* `returned` passed off as the total). Records are surfaced VERBATIM (each dataset
|
|
42
|
+
* has its own enforcement schema; blind coercion would distort compliance data — a
|
|
43
|
+
* JSON `0` stays `0`, a JSON `null` stays `null`, field names are preserved as-is).
|
|
44
|
+
*
|
|
45
|
+
* ★HONESTY (ADR-0053 P1–P4 + KEY + SSRF):
|
|
46
|
+
* [KEY] dol_get_dataset with NO DOL_API_KEY ⇒ invalid_input THROW pre-fetch (0
|
|
47
|
+
* fetch); the message names DOL_API_KEY + dol.gov/developer. The key rides the
|
|
48
|
+
* `X-API-KEY` HEADER ONLY — NEVER the URL / label / _meta / notes / a log (the
|
|
49
|
+
* K-test). dol_list_datasets is keyless (no key read, no header).
|
|
50
|
+
* [P1] catalog: totalAvailable = meta.total_count (the API's real catalog total)
|
|
51
|
+
* for an unfiltered scan; when a CLIENT-SIDE filter is applied it is the
|
|
52
|
+
* filtered-set size (exact — the whole catalog is fetched in one page). data:
|
|
53
|
+
* totalAvailable = a real count field when present, else null; offset/limit
|
|
54
|
+
* pagination in both.
|
|
55
|
+
* [P2] data: 401/403 (missing/invalid key) ⇒ invalid_input reclassified with the
|
|
56
|
+
* DOL_API_KEY guidance (the AWS "Missing Authentication Token" is a key problem
|
|
57
|
+
* here) — never empty. 400 ⇒ invalid_input. empty rows ⇒ honest empty
|
|
58
|
+
* (returned:0). 429 ⇒ rate_limited THROW (Retry-After honored). 5xx/timeout ⇒
|
|
59
|
+
* upstream_unavailable THROW. 200 non-JSON ⇒ schema_drift.
|
|
60
|
+
* [P3] records verbatim (null-never-0 comes for free — JSON preserves 0/null and
|
|
61
|
+
* every original field name; the tool never introduces a fabricated 0).
|
|
62
|
+
* [P4] the expected array (catalog `datasets`; data's row array) absent/non-array
|
|
63
|
+
* ⇒ driftError. A ToolErrorCarrier (401/5xx/…) is a P2 outcome, rethrown
|
|
64
|
+
* BEFORE the drift check.
|
|
65
|
+
* [SSRF] fixed host `apiprod.dol.gov` + a post-construction hostname/https assert +
|
|
66
|
+
* `redirect:"error"`; agency/endpoint charclass `^[A-Za-z0-9_]+$` (they ride
|
|
67
|
+
* in the PATH); limit/offset integers; filterValue/fields ride URLSearchParams;
|
|
68
|
+
* the key rides the X-API-KEY header only.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
72
|
+
import { getJson, driftError } from "./datasource.js";
|
|
73
|
+
import { num, str } from "./coerce.js";
|
|
74
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
75
|
+
|
|
76
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js) so a
|
|
77
|
+
// `num` regression fails together across sources. NO local num/str.
|
|
78
|
+
export { num };
|
|
79
|
+
|
|
80
|
+
// ─── SSRF core: the single fixed host + base path ─────────────────
|
|
81
|
+
export const DOL_HOST = "apiprod.dol.gov";
|
|
82
|
+
const DOL_DATASETS_PATH = "/v4/datasets";
|
|
83
|
+
// HOST+path labels — surface in ToolError.upstreamEndpoint; the key rides ONLY in the
|
|
84
|
+
// X-API-KEY header, so no token can ever appear here.
|
|
85
|
+
const DOL_DATASETS_LABEL = "dol:/v4/datasets";
|
|
86
|
+
const DOL_GET_LABEL = "dol:/v4/get";
|
|
87
|
+
|
|
88
|
+
// ─── Validation charclasses (SSRF + "verify the input" honesty) ───
|
|
89
|
+
// agency (abbr) + endpoint (api_url / tablename) ride in the request PATH — strict
|
|
90
|
+
// alnum+underscore rejects '/', '.', '..', spaces, and any path-steering char.
|
|
91
|
+
const AGENCY_RE = /^[A-Za-z0-9_]+$/;
|
|
92
|
+
const ENDPOINT_RE = /^[A-Za-z0-9_]+$/;
|
|
93
|
+
|
|
94
|
+
// The whole DOL dataset catalog is small (~42 rows) and returns in a single page when
|
|
95
|
+
// `limit` is large. Fetch it in one page so agency/query filtering is over the COMPLETE
|
|
96
|
+
// catalog (and totalAvailable on a filtered result is exact). meta.total_count is the
|
|
97
|
+
// ground truth — if the catalog ever exceeds this, the honest short-fetch is disclosed.
|
|
98
|
+
const CATALOG_FETCH_LIMIT = 1000;
|
|
99
|
+
|
|
100
|
+
const DEFAULT_GET_LIMIT = 10;
|
|
101
|
+
const MAX_GET_LIMIT = 100;
|
|
102
|
+
const DEFAULT_LIST_LIMIT = 25;
|
|
103
|
+
|
|
104
|
+
// ─── Honesty notes ────────────────────────────────────────────────
|
|
105
|
+
const KEY_REQUIRED_NOTE =
|
|
106
|
+
"dol_get_dataset REQUIRES a free DOL_API_KEY (the DOL data endpoint has no keyless tier; the CATALOG — dol_list_datasets — and agency list are keyless). Get a key at https://dol.gov/developer. The key is sent ONLY in the X-API-KEY request header and is NEVER logged, echoed, or placed in this response.";
|
|
107
|
+
const DATA_ENVELOPE_NOTE =
|
|
108
|
+
"The DOL data-record envelope is key-gated and could not be verified live, so records are returned VERBATIM (each dataset has its own enforcement schema — field names and values are preserved as-is; a value is NOT coerced, so a genuine 0 stays 0 and a missing field stays null).";
|
|
109
|
+
const DATA_NO_TOTAL_NOTE =
|
|
110
|
+
"totalAvailable is null: the DOL data endpoint reports no match count in a form this tool could verify. Page with limit/offset — when a full page is returned hasMore is true (page forward to confirm); an empty next page means the end. `returned` is NEVER passed off as the total.";
|
|
111
|
+
const CATALOG_ENDPOINT_NOTE =
|
|
112
|
+
"Feed a row's `apiUrl` (the dataset endpoint) plus its `agencyAbbr` into dol_get_dataset to fetch that dataset's records. agency/query filtering here is CLIENT-SIDE (the DOL catalog API does not filter server-side).";
|
|
113
|
+
|
|
114
|
+
// ─── The key seam (REQUIRED for the data tool; value NEVER leaked past the header) ──
|
|
115
|
+
/** Read DOL_API_KEY from env; trim; return the value or undefined (unset/blank). */
|
|
116
|
+
export function dolApiKey(): string | undefined {
|
|
117
|
+
const raw = process.env.DOL_API_KEY;
|
|
118
|
+
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
|
119
|
+
return trimmed ? trimmed : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── SSRF-guarded URL builder (fixed host + hostname assertion) ────
|
|
123
|
+
/**
|
|
124
|
+
* Build + assert an apiprod.dol.gov URL on the FIXED host. `path` is a fixed/validated
|
|
125
|
+
* path; `params` (URLSearchParams, encoded) carry all caller VALUES. Asserts the
|
|
126
|
+
* CONSTRUCTED URL's hostname === the fixed host over https (belt-and-suspenders behind
|
|
127
|
+
* the fixed literal), throwing invalid_input on any mismatch (SSRF safety).
|
|
128
|
+
*/
|
|
129
|
+
function buildDolUrl(path: string, label: string, params: URLSearchParams): string {
|
|
130
|
+
const qs = params.toString();
|
|
131
|
+
const url = `https://${DOL_HOST}${path}${qs ? `?${qs}` : ""}`;
|
|
132
|
+
const built = new URL(url);
|
|
133
|
+
if (built.hostname !== DOL_HOST || built.protocol !== "https:") {
|
|
134
|
+
throw new ToolErrorCarrier({
|
|
135
|
+
kind: "invalid_input",
|
|
136
|
+
retryable: false,
|
|
137
|
+
message: `Constructed DOL URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${DOL_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
138
|
+
upstreamEndpoint: label,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Tool: dol_list_datasets (KEYLESS) ─────────────────────────────
|
|
145
|
+
export type DolListDatasetsArgs = {
|
|
146
|
+
agency?: string;
|
|
147
|
+
query?: string;
|
|
148
|
+
limit?: number;
|
|
149
|
+
offset?: number;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type DolDataset = {
|
|
153
|
+
name: string | null;
|
|
154
|
+
tablename: string | null;
|
|
155
|
+
apiUrl: string | null; // the /v4/get endpoint segment — feed to dol_get_dataset
|
|
156
|
+
agency: string | null; // the agency full name
|
|
157
|
+
agencyAbbr: string | null; // the agency abbreviation (used as the /v4/get {agency})
|
|
158
|
+
description: string | null;
|
|
159
|
+
frequency: string | null;
|
|
160
|
+
datasetType: number | null;
|
|
161
|
+
category: string | null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Map ONE catalog `datasets[]` row → the curated dataset shape (every scalar via str/num). */
|
|
165
|
+
function mapDataset(row: unknown): DolDataset {
|
|
166
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
167
|
+
const agency = (r.agency ?? {}) as Record<string, unknown>;
|
|
168
|
+
const category = (r.category ?? {}) as Record<string, unknown>;
|
|
169
|
+
return {
|
|
170
|
+
name: str(r.name),
|
|
171
|
+
tablename: str(r.tablename),
|
|
172
|
+
apiUrl: str(r.api_url),
|
|
173
|
+
agency: str(agency.name),
|
|
174
|
+
agencyAbbr: str(agency.abbr),
|
|
175
|
+
description: str(r.description),
|
|
176
|
+
frequency: str(r.frequency),
|
|
177
|
+
datasetType: num(r.dataset_type),
|
|
178
|
+
category: str(r.category_name ?? category.name),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List the DOL Data API v4 dataset catalog (KEYLESS). Fetches the WHOLE catalog in
|
|
184
|
+
* one page, applies optional CLIENT-SIDE `agency` (abbr/name) + `query` (substring over
|
|
185
|
+
* name/description/tags) filters, then offset/limit-slices the filtered set. Honest
|
|
186
|
+
* `_meta`: totalAvailable = the catalog's real total (unfiltered) or the filtered-set
|
|
187
|
+
* size (exact — the whole catalog is in hand); offset pagination over the filtered set.
|
|
188
|
+
*/
|
|
189
|
+
export async function listDatasets(args: DolListDatasetsArgs): Promise<MetaBundle> {
|
|
190
|
+
const label = DOL_DATASETS_LABEL;
|
|
191
|
+
const limit = clampLimit(args.limit, DEFAULT_LIST_LIMIT, 200);
|
|
192
|
+
const offset = clampOffset(args.offset);
|
|
193
|
+
|
|
194
|
+
// Fetch the whole catalog in one page (KEYLESS — no key read, no header).
|
|
195
|
+
const params = new URLSearchParams();
|
|
196
|
+
params.set("limit", String(CATALOG_FETCH_LIMIT));
|
|
197
|
+
const url = buildDolUrl(DOL_DATASETS_PATH, label, params);
|
|
198
|
+
|
|
199
|
+
let body: unknown;
|
|
200
|
+
try {
|
|
201
|
+
body = await getJson<unknown>(url, { label, redirect: "error" });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
204
|
+
if (e instanceof SyntaxError)
|
|
205
|
+
throw driftError(
|
|
206
|
+
label,
|
|
207
|
+
"DOL /v4/datasets returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
208
|
+
);
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// [P4] `datasets` MUST be an array (a missing/non-array is drift, never a fabricated empty).
|
|
213
|
+
const b = (body ?? {}) as { datasets?: unknown; meta?: unknown };
|
|
214
|
+
if (!Array.isArray(b.datasets)) {
|
|
215
|
+
throw driftError(
|
|
216
|
+
label,
|
|
217
|
+
"DOL /v4/datasets shape drift — `datasets` must be an array.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const meta = (b.meta ?? {}) as Record<string, unknown>;
|
|
222
|
+
const catalogTotal = num(meta.total_count); // the API's real catalog total (P1)
|
|
223
|
+
const fetched = (b.datasets as unknown[]).map(mapDataset);
|
|
224
|
+
|
|
225
|
+
// ── Optional CLIENT-SIDE filters (the DOL catalog API does not filter server-side). ──
|
|
226
|
+
const filtersApplied: string[] = [];
|
|
227
|
+
let filtered = fetched;
|
|
228
|
+
if (args.agency !== undefined && args.agency.trim() !== "") {
|
|
229
|
+
const needle = args.agency.trim().toLowerCase();
|
|
230
|
+
filtered = filtered.filter(
|
|
231
|
+
(d) =>
|
|
232
|
+
(d.agencyAbbr !== null && d.agencyAbbr.toLowerCase() === needle) ||
|
|
233
|
+
(d.agency !== null && d.agency.toLowerCase().includes(needle)),
|
|
234
|
+
);
|
|
235
|
+
filtersApplied.push(`agency:${args.agency.trim()}`);
|
|
236
|
+
}
|
|
237
|
+
if (args.query !== undefined && args.query.trim() !== "") {
|
|
238
|
+
const q = args.query.trim().toLowerCase();
|
|
239
|
+
// Substring over name + description + category (the human-searchable text).
|
|
240
|
+
filtered = filtered.filter((d) =>
|
|
241
|
+
[d.name, d.description, d.category, d.tablename, d.apiUrl]
|
|
242
|
+
.filter((v): v is string => v !== null)
|
|
243
|
+
.some((v) => v.toLowerCase().includes(q)),
|
|
244
|
+
);
|
|
245
|
+
filtersApplied.push(`query:${args.query.trim()}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const anyFilter = filtersApplied.length > 0;
|
|
249
|
+
// [P1] totalAvailable: unfiltered ⇒ the API's real catalog total; filtered ⇒ the
|
|
250
|
+
// filtered-set size (EXACT — the whole catalog was fetched in one page). NEVER the
|
|
251
|
+
// page length passed off as a total.
|
|
252
|
+
const totalAvailable = anyFilter
|
|
253
|
+
? filtered.length
|
|
254
|
+
: catalogTotal !== null
|
|
255
|
+
? catalogTotal
|
|
256
|
+
: filtered.length;
|
|
257
|
+
|
|
258
|
+
const page = filtered.slice(offset, offset + limit);
|
|
259
|
+
const returned = page.length;
|
|
260
|
+
const hasMore = offset + returned < filtered.length;
|
|
261
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
262
|
+
|
|
263
|
+
const notes: string[] = [CATALOG_ENDPOINT_NOTE];
|
|
264
|
+
// Honest disclosure if the catalog ever exceeds one fetch page (client-side filtering
|
|
265
|
+
// would then be over a subset). Does not happen at the current ~42-row catalog.
|
|
266
|
+
if (catalogTotal !== null && fetched.length < catalogTotal) {
|
|
267
|
+
notes.push(
|
|
268
|
+
`Only the first ${fetched.length} of ${catalogTotal} catalog datasets were retrieved; any agency/query filtering (and totalAvailable) is over that subset. Narrow with agency/query.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return withMeta(
|
|
273
|
+
{ datasets: page },
|
|
274
|
+
{
|
|
275
|
+
source: `${DOL_HOST} /v4/datasets (US DOL Data API v4 catalog; keyless)`,
|
|
276
|
+
keylessMode: true,
|
|
277
|
+
returned,
|
|
278
|
+
totalAvailable,
|
|
279
|
+
filtersApplied,
|
|
280
|
+
filtersDropped: [],
|
|
281
|
+
fieldsUnavailable: [],
|
|
282
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
283
|
+
notes,
|
|
284
|
+
} satisfies Partial<ResponseMeta>,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Tool: dol_get_dataset (KEY-REQUIRED) ──────────────────────────
|
|
289
|
+
export type DolGetDatasetArgs = {
|
|
290
|
+
agency?: string;
|
|
291
|
+
table?: string;
|
|
292
|
+
limit?: number;
|
|
293
|
+
offset?: number;
|
|
294
|
+
filterField?: string;
|
|
295
|
+
filterValue?: string;
|
|
296
|
+
fields?: string[];
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Fetch records from one DOL dataset (KEY-REQUIRED). `agency` (abbr) + `table` (the
|
|
301
|
+
* dataset's api_url endpoint) ride the request PATH; limit/offset/filter ride the query;
|
|
302
|
+
* the DOL_API_KEY rides the X-API-KEY header ONLY. Records are surfaced VERBATIM. Honest
|
|
303
|
+
* `_meta`: totalAvailable is a real count field when present, else null (offset
|
|
304
|
+
* pagination). Unset key ⇒ invalid_input THROW pre-fetch (0 fetch).
|
|
305
|
+
*/
|
|
306
|
+
export async function getDataset(args: DolGetDatasetArgs): Promise<MetaBundle> {
|
|
307
|
+
const label = DOL_GET_LABEL;
|
|
308
|
+
|
|
309
|
+
// ── [KEY] REQUIRED key — throw an honest config error BEFORE any fetch. ──
|
|
310
|
+
const key = dolApiKey();
|
|
311
|
+
if (key === undefined) {
|
|
312
|
+
throw new ToolErrorCarrier({
|
|
313
|
+
kind: "invalid_input",
|
|
314
|
+
retryable: false,
|
|
315
|
+
message:
|
|
316
|
+
"The DOL data endpoint requires a free DOL_API_KEY (the dataset CATALOG, dol_list_datasets, is keyless). Get one at https://dol.gov/developer and set DOL_API_KEY.",
|
|
317
|
+
upstreamEndpoint: label,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Validate inputs (belt-and-suspenders behind the server Zod; a DIRECT handler
|
|
322
|
+
// call bypasses Zod — agency/table ride in the PATH). ──
|
|
323
|
+
const agency = args.agency ?? "";
|
|
324
|
+
if (!AGENCY_RE.test(agency)) {
|
|
325
|
+
throw new ToolErrorCarrier({
|
|
326
|
+
kind: "invalid_input",
|
|
327
|
+
retryable: false,
|
|
328
|
+
message: `Invalid agency ${JSON.stringify(agency)} — expected an agency abbreviation (^[A-Za-z0-9_]+$), e.g. "WHD", "OSHA", "ILAB". Discover it as agencyAbbr from dol_list_datasets. (agency rides in the request PATH; it is strictly validated.)`,
|
|
329
|
+
upstreamEndpoint: label,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const table = args.table ?? "";
|
|
333
|
+
if (!ENDPOINT_RE.test(table)) {
|
|
334
|
+
throw new ToolErrorCarrier({
|
|
335
|
+
kind: "invalid_input",
|
|
336
|
+
retryable: false,
|
|
337
|
+
message: `Invalid table ${JSON.stringify(table)} — expected a dataset endpoint (^[A-Za-z0-9_]+$), the dataset's api_url from dol_list_datasets. (table rides in the request PATH; it is strictly validated.)`,
|
|
338
|
+
upstreamEndpoint: label,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// filterField/filterValue are paired — one without the other is a caller error.
|
|
342
|
+
if (
|
|
343
|
+
(args.filterField !== undefined) !== (args.filterValue !== undefined)
|
|
344
|
+
) {
|
|
345
|
+
throw new ToolErrorCarrier({
|
|
346
|
+
kind: "invalid_input",
|
|
347
|
+
retryable: false,
|
|
348
|
+
message:
|
|
349
|
+
"filterField and filterValue must be supplied TOGETHER (a field to filter on plus the value to match).",
|
|
350
|
+
upstreamEndpoint: label,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const limit = clampLimit(args.limit, DEFAULT_GET_LIMIT, MAX_GET_LIMIT);
|
|
355
|
+
const offset = clampOffset(args.offset);
|
|
356
|
+
|
|
357
|
+
// ── Build the query (all VALUES via URLSearchParams — no host/path steer). The
|
|
358
|
+
// format is a PATH segment (/json per the DOL User Guide); the key rides the
|
|
359
|
+
// X-API-KEY HEADER, never the query. ──
|
|
360
|
+
const params = new URLSearchParams();
|
|
361
|
+
params.set("limit", String(limit));
|
|
362
|
+
params.set("offset", String(offset));
|
|
363
|
+
const filtersApplied: string[] = [];
|
|
364
|
+
if (args.filterField !== undefined && args.filterValue !== undefined) {
|
|
365
|
+
// DOL's documented filter mechanism: a JSON filter_object (field/operator/value).
|
|
366
|
+
params.set(
|
|
367
|
+
"filter_object",
|
|
368
|
+
JSON.stringify({
|
|
369
|
+
field: args.filterField,
|
|
370
|
+
operator: "eq",
|
|
371
|
+
value: args.filterValue,
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
filtersApplied.push(`${args.filterField}:${args.filterValue}`);
|
|
375
|
+
}
|
|
376
|
+
if (args.fields !== undefined && args.fields.length > 0) {
|
|
377
|
+
// Best-effort column selection (not documented for v4; the API ignores or 400s an
|
|
378
|
+
// unsupported param — the 400 path surfaces it honestly).
|
|
379
|
+
params.set("fields", args.fields.join(","));
|
|
380
|
+
filtersApplied.push(`fields:${args.fields.join(",")}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const path = `/v4/get/${agency}/${table}/json`;
|
|
384
|
+
const url = buildDolUrl(path, label, params);
|
|
385
|
+
|
|
386
|
+
// ── Fetch through the shared envelope: the key rides the X-API-KEY header ONLY;
|
|
387
|
+
// redirect:"error" (fail closed on any off-host 3xx — it could carry the key
|
|
388
|
+
// away). The 401/403 key-error and 400 are reclassified below. ──
|
|
389
|
+
let body: unknown;
|
|
390
|
+
try {
|
|
391
|
+
body = await getJson<unknown>(url, {
|
|
392
|
+
label,
|
|
393
|
+
headers: { "X-API-KEY": key },
|
|
394
|
+
redirect: "error",
|
|
395
|
+
});
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (e instanceof ToolErrorCarrier) {
|
|
398
|
+
const status = e.toolError.upstreamStatus;
|
|
399
|
+
// [P2/KEY] 401/403 (missing/invalid key — the AWS "Missing Authentication Token"
|
|
400
|
+
// or the DOL app's key-rejection) ⇒ invalid_input carrying the DOL_API_KEY
|
|
401
|
+
// guidance, NEVER an empty result.
|
|
402
|
+
if (status === 401 || status === 403) {
|
|
403
|
+
throw new ToolErrorCarrier({
|
|
404
|
+
kind: "invalid_input",
|
|
405
|
+
retryable: false,
|
|
406
|
+
message:
|
|
407
|
+
"DOL rejected the request as unauthorized (HTTP 401/403) — DOL_API_KEY is missing or invalid. Check the key (free at https://dol.gov/developer).",
|
|
408
|
+
upstreamStatus: status,
|
|
409
|
+
upstreamEndpoint: label,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
throw e; // 400 → invalid_input, 429 → rate_limited, 5xx → upstream_unavailable, 404 → not_found …
|
|
413
|
+
}
|
|
414
|
+
if (e instanceof SyntaxError)
|
|
415
|
+
throw driftError(
|
|
416
|
+
label,
|
|
417
|
+
"DOL /v4/get returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
418
|
+
);
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── [P4] Resolve the row array DEFENSIVELY (envelope unverified — key-gated): a bare
|
|
423
|
+
// array, or `{ data:[…] }`, or `{ results:[…] }`. None ⇒ driftError. ──
|
|
424
|
+
const rows = resolveRows(body);
|
|
425
|
+
if (rows === null) {
|
|
426
|
+
throw driftError(
|
|
427
|
+
label,
|
|
428
|
+
"DOL /v4/get body carries no row array (expected a bare array, or a `data`/`results` array) — schema drift (never a fabricated empty).",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// [P4] Every row must be a JSON object; surface it VERBATIM (P3 — preserve field
|
|
433
|
+
// names + values; a JSON 0 stays 0, a JSON null stays null, no coercion).
|
|
434
|
+
const records: Record<string, unknown>[] = [];
|
|
435
|
+
for (let i = 0; i < rows.length; i++) {
|
|
436
|
+
const raw = rows[i];
|
|
437
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
438
|
+
throw driftError(
|
|
439
|
+
label,
|
|
440
|
+
`DOL /v4/get record ${i} is not a JSON object — schema drift (never a fabricated empty).`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
records.push({ ...(raw as Record<string, unknown>) });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── [P1] totalAvailable: use a real count field ONLY if present (defensive across
|
|
447
|
+
// the unverified envelope), else null (an honest unknown — never `returned`). ──
|
|
448
|
+
const totalAvailable = resolveCount(body);
|
|
449
|
+
const returned = records.length;
|
|
450
|
+
// Unknown total ⇒ a FULL page suggests more (page forward to confirm — an empty next
|
|
451
|
+
// page is the end); a partial page is the last. Never over-claims a fabricated total.
|
|
452
|
+
const hasMore =
|
|
453
|
+
totalAvailable !== null
|
|
454
|
+
? offset + returned < totalAvailable
|
|
455
|
+
: returned > 0 && returned === limit;
|
|
456
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
457
|
+
|
|
458
|
+
const notes: string[] = [KEY_REQUIRED_NOTE, DATA_ENVELOPE_NOTE];
|
|
459
|
+
if (totalAvailable === null) notes.push(DATA_NO_TOTAL_NOTE);
|
|
460
|
+
|
|
461
|
+
return withMeta(
|
|
462
|
+
{ records },
|
|
463
|
+
{
|
|
464
|
+
source: `${DOL_HOST} /v4/get/${agency}/${table} (US DOL Data API v4; DOL_API_KEY)`,
|
|
465
|
+
keylessMode: false, // ★KEYED — the data endpoint has no keyless tier
|
|
466
|
+
returned,
|
|
467
|
+
totalAvailable,
|
|
468
|
+
filtersApplied,
|
|
469
|
+
filtersDropped: [],
|
|
470
|
+
fieldsUnavailable: [],
|
|
471
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
472
|
+
notes,
|
|
473
|
+
} satisfies Partial<ResponseMeta>,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Defensively resolve the row array from the (unverified) DOL data envelope. null ⇒ none. */
|
|
478
|
+
function resolveRows(body: unknown): unknown[] | null {
|
|
479
|
+
if (Array.isArray(body)) return body;
|
|
480
|
+
const b = (body ?? {}) as { data?: unknown; results?: unknown };
|
|
481
|
+
if (Array.isArray(b.data)) return b.data;
|
|
482
|
+
if (Array.isArray(b.results)) return b.results;
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Defensively read a real total-count field from the (unverified) DOL data envelope.
|
|
488
|
+
* Checks the common carriers (top-level total_count/total/count, or meta.total_count);
|
|
489
|
+
* returns null when NONE is present (an honest unknown — never a fabricated total).
|
|
490
|
+
*/
|
|
491
|
+
function resolveCount(body: unknown): number | null {
|
|
492
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
493
|
+
const b = body as Record<string, unknown>;
|
|
494
|
+
const meta = (b.meta ?? {}) as Record<string, unknown>;
|
|
495
|
+
for (const v of [b.total_count, b.total, b.count, meta.total_count]) {
|
|
496
|
+
const n = num(v);
|
|
497
|
+
if (n !== null) return n;
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ─── Small shared clamps (defensive, behind the server Zod bounds) ──
|
|
503
|
+
function clampLimit(v: unknown, def: number, max: number): number {
|
|
504
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return def;
|
|
505
|
+
const n = Math.floor(v);
|
|
506
|
+
if (n < 1) return 1;
|
|
507
|
+
if (n > max) return max;
|
|
508
|
+
return n;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function clampOffset(v: unknown): number {
|
|
512
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return 0;
|
|
513
|
+
const n = Math.floor(v);
|
|
514
|
+
return n < 0 ? 0 : n;
|
|
515
|
+
}
|
package/src/keys.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Why this exists
|
|
5
5
|
* ----------------
|
|
6
6
|
* The server rides 31 federal sources. MOST are fully keyless. But the set of
|
|
7
|
-
* *optional* keys (raise a rate limit, unlock one filter) plus the
|
|
8
|
-
* keys (Census business-patterns, FRED) has grown to the point where a user — or
|
|
7
|
+
* *optional* keys (raise a rate limit, unlock one filter) plus the four *required*
|
|
8
|
+
* keys (Census business-patterns, FRED, BEA Regional, DOL data) has grown to the point where a user — or
|
|
9
9
|
* the AI driving the server — cannot tell, without reading source code:
|
|
10
10
|
* - which env var each source reads,
|
|
11
11
|
* - whether a key is REQUIRED or merely OPTIONAL,
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* Grounding: every `envVar` below is the exact string the code reads via
|
|
20
20
|
* `process.env.<NAME>` — DATA_GOV_API_KEY (datagovKey.ts), SAM_GOV_API_KEY
|
|
21
21
|
* (server.ts), BLS_API_KEY (bls.ts), NVD_API_KEY (nvd.ts), SOCRATA_APP_TOKEN
|
|
22
|
-
* (socrata.ts), CENSUS_API_KEY (census-economic.ts), FRED_API_KEY (fred.ts)
|
|
23
|
-
*
|
|
22
|
+
* (socrata.ts), CENSUS_API_KEY (census-economic.ts), FRED_API_KEY (fred.ts),
|
|
23
|
+
* BEA_API_KEY (bea.ts), DOL_API_KEY (dol.ts), LDA_API_KEY (lda.ts). No invented
|
|
24
|
+
* keys, sources, or signup URLs.
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
import { readFileSync } from "node:fs";
|
|
@@ -43,11 +44,12 @@ export type KeyRegistryEntry = {
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
* The
|
|
47
|
+
* The 10 keys the server reads — code-grounded, no inventions.
|
|
47
48
|
*
|
|
48
|
-
* REQUIRED (
|
|
49
|
-
* tier, so the tool throws without them
|
|
50
|
-
*
|
|
49
|
+
* REQUIRED (4): CENSUS_API_KEY, FRED_API_KEY, BEA_API_KEY, DOL_API_KEY — those sources
|
|
50
|
+
* have no keyless tier, so the tool throws without them (DOL_API_KEY gates ONLY
|
|
51
|
+
* dol_get_dataset; the DOL catalog, dol_list_datasets, is keyless). OPTIONAL (6):
|
|
52
|
+
* everything else works keyless; a key only raises a rate limit or unlocks a single filter.
|
|
51
53
|
*/
|
|
52
54
|
export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
|
|
53
55
|
{
|
|
@@ -114,6 +116,35 @@ export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
|
|
|
114
116
|
"the 2 FRED tools (there is no keyless tier — they throw without a key)",
|
|
115
117
|
note: "REQUIRED: the FRED API has no keyless access.",
|
|
116
118
|
},
|
|
119
|
+
{
|
|
120
|
+
envVar: "BEA_API_KEY",
|
|
121
|
+
sources: ["BEA Regional Economic Accounts (bea_regional_data)"],
|
|
122
|
+
required: true,
|
|
123
|
+
signupUrl: "https://apps.bea.gov/API/signup/",
|
|
124
|
+
unlocks:
|
|
125
|
+
"the bea_regional_data tool (there is no keyless tier — it throws without a key)",
|
|
126
|
+
note: "REQUIRED: the BEA Data API has no keyless access.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
envVar: "DOL_API_KEY",
|
|
130
|
+
sources: [
|
|
131
|
+
"US DOL enforcement data (dol_get_dataset; dol_list_datasets is keyless)",
|
|
132
|
+
],
|
|
133
|
+
required: true,
|
|
134
|
+
signupUrl: "https://dol.gov/developer",
|
|
135
|
+
unlocks:
|
|
136
|
+
"the dol_get_dataset tool (dataset records need a free key; dol_list_datasets works keyless)",
|
|
137
|
+
note: "The DOL data endpoint needs a free key; the dataset CATALOG (dol_list_datasets) and agency list are keyless.",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
envVar: "LDA_API_KEY",
|
|
141
|
+
sources: ["US Senate LDA lobbying (lda_search_filings)"],
|
|
142
|
+
required: false,
|
|
143
|
+
signupUrl: "https://lda.senate.gov/api/register/",
|
|
144
|
+
unlocks:
|
|
145
|
+
"higher LDA API rate limits (anonymous access already works without it)",
|
|
146
|
+
note: "Keyless by default (anonymous 200); a free token only raises the rate limit.",
|
|
147
|
+
},
|
|
117
148
|
] as const;
|
|
118
149
|
|
|
119
150
|
/** true iff the env var is set to a non-empty (after-trim) string. */
|