@cliwant/mcp-sam-gov 1.2.0 → 1.4.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 +22 -9
- package/README.ko.md +22 -9
- package/README.md +70 -12
- 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/census-economic.d.ts +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +12 -6
- package/dist/census-economic.js.map +1 -1
- package/dist/cms-facility.d.ts +112 -0
- package/dist/cms-facility.d.ts.map +1 -0
- package/dist/cms-facility.js +311 -0
- package/dist/cms-facility.js.map +1 -0
- package/dist/cms-hospital.d.ts +105 -0
- package/dist/cms-hospital.d.ts.map +1 -0
- package/dist/cms-hospital.js +290 -0
- package/dist/cms-hospital.js.map +1 -0
- package/dist/cms-supplier.d.ts +133 -0
- package/dist/cms-supplier.d.ts.map +1 -0
- package/dist/cms-supplier.js +414 -0
- package/dist/cms-supplier.js.map +1 -0
- package/dist/cms-utilization.d.ts +113 -0
- package/dist/cms-utilization.d.ts.map +1 -0
- package/dist/cms-utilization.js +328 -0
- package/dist/cms-utilization.js.map +1 -0
- package/dist/courtlistener.d.ts +115 -0
- package/dist/courtlistener.d.ts.map +1 -0
- package/dist/courtlistener.js +398 -0
- package/dist/courtlistener.js.map +1 -0
- package/dist/cpsc.d.ts +81 -0
- package/dist/cpsc.d.ts.map +1 -0
- package/dist/cpsc.js +283 -0
- package/dist/cpsc.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/epa-envirofacts.d.ts +97 -0
- package/dist/epa-envirofacts.d.ts.map +1 -0
- package/dist/epa-envirofacts.js +292 -0
- package/dist/epa-envirofacts.js.map +1 -0
- package/dist/fred.d.ts +1 -1
- package/dist/fred.js +1 -1
- package/dist/keys.d.ts +11 -8
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +55 -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/nhtsa.d.ts +91 -0
- package/dist/nhtsa.d.ts.map +1 -0
- package/dist/nhtsa.js +263 -0
- package/dist/nhtsa.js.map +1 -0
- package/dist/nonprofit.d.ts +116 -0
- package/dist/nonprofit.d.ts.map +1 -0
- package/dist/nonprofit.js +342 -0
- package/dist/nonprofit.js.map +1 -0
- package/dist/openfda-device.d.ts +85 -0
- package/dist/openfda-device.d.ts.map +1 -0
- package/dist/openfda-device.js +277 -0
- package/dist/openfda-device.js.map +1 -0
- package/dist/openfda.d.ts +133 -0
- package/dist/openfda.d.ts.map +1 -0
- package/dist/openfda.js +402 -0
- package/dist/openfda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +872 -6
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/src/bea.ts +372 -0
- package/src/census-economic.ts +12 -6
- package/src/cms-facility.ts +379 -0
- package/src/cms-hospital.ts +344 -0
- package/src/cms-supplier.ts +527 -0
- package/src/cms-utilization.ts +389 -0
- package/src/courtlistener.ts +465 -0
- package/src/cpsc.ts +333 -0
- package/src/dol.ts +515 -0
- package/src/epa-envirofacts.ts +342 -0
- package/src/fred.ts +1 -1
- package/src/keys.ts +60 -8
- package/src/lda.ts +385 -0
- package/src/nhtsa.ts +352 -0
- package/src/nonprofit.ts +460 -0
- package/src/openfda-device.ts +356 -0
- package/src/openfda.ts +495 -0
- package/src/server.ts +995 -6
package/src/cpsc.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cpsc.ts — CPSC CONSUMER-PRODUCT RECALLS (www.saferproducts.gov) — the consumer
|
|
3
|
+
* goods / import product-safety vetting lane (ADR-0058). ONE keyless tool:
|
|
4
|
+
* • cpsc_recalls — /RestWebServices/Recall?format=json + date/product/manufacturer/
|
|
5
|
+
* recallNumber filters.
|
|
6
|
+
* The third leg of the cross-agency product-safety family alongside NHTSA (vehicles)
|
|
7
|
+
* and openFDA (medical/food): a manufacturer / product / hazard recall history for
|
|
8
|
+
* B2G supplier and import vetting.
|
|
9
|
+
*
|
|
10
|
+
* ★ KEYLESS — there is NO API key at all (no parameter, no header). This module
|
|
11
|
+
* touches NO key seam (no KEY_REGISTRY / keys.ts / API_KEYS.md). It REUSES the
|
|
12
|
+
* shared `getJson` (redirect:"error") / `driftError` fetch envelope, the `str`
|
|
13
|
+
* coercion (null-never-empty-string), and `withMeta`/`buildMeta` — and mirrors
|
|
14
|
+
* nhtsa.ts / datagov-catalog.ts's fixed-host SSRF idiom + schema_drift
|
|
15
|
+
* catch-ladder verbatim.
|
|
16
|
+
*
|
|
17
|
+
* ★ THE HONESTY PILLARS (P1-P5, live-verified 2026-07-15):
|
|
18
|
+
* P1: the /Recall response is a BARE JSON ARRAY with NO count field and NO server
|
|
19
|
+
* pagination — it returns the COMPLETE set matching the filter. So
|
|
20
|
+
* totalAvailable = results.length and complete:true, WITH a disclosing note
|
|
21
|
+
* that CPSC reports no total-count field / no pagination. A total is NEVER
|
|
22
|
+
* fabricated (there is no upstream total to trust; the honest total is the
|
|
23
|
+
* length of the complete set).
|
|
24
|
+
* P2: an empty array `[]` ⇒ an HONEST EMPTY (returned:0, complete:true) — a filter
|
|
25
|
+
* that matches nothing is an honest no-match, NOT an error. A 4xx ⇒
|
|
26
|
+
* invalid_input; a 5xx/timeout ⇒ THROW (never a fake empty); a 200 non-JSON
|
|
27
|
+
* body OR a non-array body ⇒ schema_drift (never a fabricated empty).
|
|
28
|
+
* P3: dates stay STRINGS (via `str`); NumberOfUnits is free text ("About 6,500")
|
|
29
|
+
* kept as a STRING; nested arrays (Products/Manufacturers/Retailers/Hazards/
|
|
30
|
+
* Remedies/Injuries/ManufacturerCountries) are flattened to string arrays by
|
|
31
|
+
* extracting each object's `.Name` (★ManufacturerCountries uses `.Country`,
|
|
32
|
+
* NOT `.Name`), SKIPPING an empty `{}` object (never a fabricated entry); a
|
|
33
|
+
* genuinely-absent nested array ⇒ []; null-never-empty-string throughout.
|
|
34
|
+
* P4: the top-level body MUST be an array — a non-array (object/string/null) ⇒
|
|
35
|
+
* driftError (a broken response contract, never a fabricated empty).
|
|
36
|
+
* DEFAULT-WINDOW: with NO filter given, the unfiltered result is huge, so the tool
|
|
37
|
+
* defaults RecallDateStart to ~90 days ago and DISCLOSES the default in a note
|
|
38
|
+
* — it NEVER silently fetches the whole dataset.
|
|
39
|
+
* SSRF: fixed host `www.saferproducts.gov` (compile-time literal) + post-construction
|
|
40
|
+
* hostname/protocol assertion + redirect:"error"; every filter rides a
|
|
41
|
+
* module-built URLSearchParams (no raw passthrough); dates are ^\d{4}-\d{2}-\d{2}$;
|
|
42
|
+
* recallNumber is charclass-validated (letters/digits/hyphen only), so a
|
|
43
|
+
* `../` or `%` can never reach the fixed path.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
47
|
+
import { getJson, driftError } from "./datasource.js";
|
|
48
|
+
import { str } from "./coerce.js";
|
|
49
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
50
|
+
|
|
51
|
+
// ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
|
|
52
|
+
export const CPSC_HOST = "www.saferproducts.gov";
|
|
53
|
+
const CPSC_RECALL_PATH = "/RestWebServices/Recall";
|
|
54
|
+
// HOST+path-only label (→ ToolError.upstreamEndpoint). Keyless ⇒ no token can ever
|
|
55
|
+
// appear here regardless, but the label stays host+path for consistency.
|
|
56
|
+
const CPSC_RECALL_LABEL = "cpsc:/RestWebServices/Recall";
|
|
57
|
+
|
|
58
|
+
// ─── Input validation grammar (SSRF + injection guard) ────────────
|
|
59
|
+
// dates: strict YYYY-MM-DD. recallNumber: letters/digits/hyphen only — rejects
|
|
60
|
+
// `../`, `%`, `/`, `.`, spaces, quotes, so a value can never break out of the
|
|
61
|
+
// URLSearchParams-encoded query onto the fixed host/path.
|
|
62
|
+
export const CPSC_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
63
|
+
export const CPSC_RECALL_NUMBER_RE = /^[A-Za-z0-9-]+$/;
|
|
64
|
+
|
|
65
|
+
// The default recent-window span (days) applied when NO filter is given, so an
|
|
66
|
+
// unbounded whole-dataset fetch never happens silently.
|
|
67
|
+
const CPSC_DEFAULT_WINDOW_DAYS = 90;
|
|
68
|
+
|
|
69
|
+
const KEYLESS_NOTE =
|
|
70
|
+
"CPSC SaferProducts is a keyless public API (www.saferproducts.gov) — no API key is required or accepted.";
|
|
71
|
+
// The P1 load-bearing honesty caveat carried on EVERY response.
|
|
72
|
+
const CPSC_NO_PAGINATION_NOTE =
|
|
73
|
+
"CPSC returns the complete matching set (no server pagination or total-count field) — totalAvailable is the number of returned recalls (the size of the complete set), not an upstream-reported total.";
|
|
74
|
+
|
|
75
|
+
// ─── Nested-array flatteners (P3) ─────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Flatten a CPSC nested array (Products/Manufacturers/Retailers/Hazards/Remedies/
|
|
78
|
+
* Injuries/ManufacturerCountries) to a string[] by extracting `field` from each
|
|
79
|
+
* object via `str` (null-never-empty-string). An empty `{}` object (or one whose
|
|
80
|
+
* `field` is absent/blank) is SKIPPED — never a fabricated entry. A non-array
|
|
81
|
+
* (absent nested array) ⇒ [].
|
|
82
|
+
*/
|
|
83
|
+
function extractField(x: unknown, field: string): string[] {
|
|
84
|
+
if (!Array.isArray(x)) return [];
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
for (const el of x) {
|
|
87
|
+
if (el === null || typeof el !== "object") continue;
|
|
88
|
+
const v = str((el as Record<string, unknown>)[field]);
|
|
89
|
+
if (v !== null) out.push(v);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The recall-level numberOfUnits (P3): CPSC carries NumberOfUnits per PRODUCT as
|
|
96
|
+
* FREE TEXT ("About 6,500"), so this returns the FIRST product's non-blank
|
|
97
|
+
* NumberOfUnits as a STRING (never numerically coerced), or null when none is
|
|
98
|
+
* present (null-never-empty-string).
|
|
99
|
+
*/
|
|
100
|
+
function firstNumberOfUnits(products: unknown): string | null {
|
|
101
|
+
if (!Array.isArray(products)) return null;
|
|
102
|
+
for (const p of products) {
|
|
103
|
+
if (p === null || typeof p !== "object") continue;
|
|
104
|
+
const v = str((p as Record<string, unknown>).NumberOfUnits);
|
|
105
|
+
if (v !== null) return v;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Curated row shape ────────────────────────────────────────────
|
|
111
|
+
export type CpscRecall = {
|
|
112
|
+
recallNumber: string | null;
|
|
113
|
+
recallDate: string | null;
|
|
114
|
+
title: string | null;
|
|
115
|
+
description: string | null;
|
|
116
|
+
url: string | null;
|
|
117
|
+
products: string[];
|
|
118
|
+
numberOfUnits: string | null;
|
|
119
|
+
manufacturers: string[];
|
|
120
|
+
retailers: string[];
|
|
121
|
+
hazards: string[];
|
|
122
|
+
remedies: string[];
|
|
123
|
+
injuries: string[];
|
|
124
|
+
manufacturerCountries: string[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Map ONE /Recall row → the curated recall shape. Scalars via `str`
|
|
129
|
+
* (null-never-empty-string; dates stay strings). Nested arrays flattened via
|
|
130
|
+
* `extractField` on `.Name` — EXCEPT ManufacturerCountries, which carries `.Country`.
|
|
131
|
+
*/
|
|
132
|
+
function mapRecall(row: unknown): CpscRecall {
|
|
133
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
134
|
+
return {
|
|
135
|
+
recallNumber: str(r.RecallNumber),
|
|
136
|
+
recallDate: str(r.RecallDate),
|
|
137
|
+
title: str(r.Title),
|
|
138
|
+
description: str(r.Description),
|
|
139
|
+
url: str(r.URL),
|
|
140
|
+
products: extractField(r.Products, "Name"),
|
|
141
|
+
numberOfUnits: firstNumberOfUnits(r.Products),
|
|
142
|
+
manufacturers: extractField(r.Manufacturers, "Name"),
|
|
143
|
+
retailers: extractField(r.Retailers, "Name"),
|
|
144
|
+
hazards: extractField(r.Hazards, "Name"),
|
|
145
|
+
remedies: extractField(r.Remedies, "Name"),
|
|
146
|
+
injuries: extractField(r.Injuries, "Name"),
|
|
147
|
+
// ★ ManufacturerCountries objects carry `Country`, not `Name` (live-verified).
|
|
148
|
+
manufacturerCountries: extractField(r.ManufacturerCountries, "Country"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Input validation (belt-and-suspenders behind the server Zod) ──
|
|
153
|
+
export type CpscRecallsArgs = {
|
|
154
|
+
dateStart?: string;
|
|
155
|
+
dateEnd?: string;
|
|
156
|
+
productName?: string;
|
|
157
|
+
manufacturer?: string;
|
|
158
|
+
recallNumber?: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate the optional inputs PRE-fetch (0 network call). A DIRECT handler call
|
|
163
|
+
* bypasses the server Zod, so re-guard the SSRF-relevant grammars here: dates are
|
|
164
|
+
* ^\d{4}-\d{2}-\d{2}$; recallNumber is letters/digits/hyphen only. productName /
|
|
165
|
+
* manufacturer are free text (they ride URLSearchParams-encoded, so injection is
|
|
166
|
+
* neutralized by encoding — no charclass needed, but they are validated as strings).
|
|
167
|
+
*/
|
|
168
|
+
function validateArgs(args: CpscRecallsArgs): void {
|
|
169
|
+
const dateChecks: Array<[string, string | undefined]> = [
|
|
170
|
+
["dateStart", args.dateStart],
|
|
171
|
+
["dateEnd", args.dateEnd],
|
|
172
|
+
];
|
|
173
|
+
for (const [name, value] of dateChecks) {
|
|
174
|
+
if (value !== undefined && (typeof value !== "string" || !CPSC_DATE_RE.test(value))) {
|
|
175
|
+
throw new ToolErrorCarrier({
|
|
176
|
+
kind: "invalid_input",
|
|
177
|
+
retryable: false,
|
|
178
|
+
message: `Invalid ${name} ${JSON.stringify(value)} — expected a YYYY-MM-DD date (^\\d{4}-\\d{2}-\\d{2}$), e.g. "2025-01-01".`,
|
|
179
|
+
upstreamEndpoint: CPSC_RECALL_LABEL,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
args.recallNumber !== undefined &&
|
|
185
|
+
(typeof args.recallNumber !== "string" || !CPSC_RECALL_NUMBER_RE.test(args.recallNumber))
|
|
186
|
+
) {
|
|
187
|
+
throw new ToolErrorCarrier({
|
|
188
|
+
kind: "invalid_input",
|
|
189
|
+
retryable: false,
|
|
190
|
+
message: `Invalid recallNumber ${JSON.stringify(args.recallNumber)} — expected letters/digits/hyphen only (^[A-Za-z0-9-]+$), e.g. "25088".`,
|
|
191
|
+
upstreamEndpoint: CPSC_RECALL_LABEL,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const [name, value] of [
|
|
195
|
+
["productName", args.productName],
|
|
196
|
+
["manufacturer", args.manufacturer],
|
|
197
|
+
] as Array<[string, string | undefined]>) {
|
|
198
|
+
if (value !== undefined && typeof value !== "string") {
|
|
199
|
+
throw new ToolErrorCarrier({
|
|
200
|
+
kind: "invalid_input",
|
|
201
|
+
retryable: false,
|
|
202
|
+
message: `Invalid ${name} — expected a string.`,
|
|
203
|
+
upstreamEndpoint: CPSC_RECALL_LABEL,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
|
|
210
|
+
/**
|
|
211
|
+
* GET the CPSC /Recall JSON on the FIXED host. Builds
|
|
212
|
+
* `https://www.saferproducts.gov/RestWebServices/Recall?${params}`, asserts the
|
|
213
|
+
* CONSTRUCTED URL's hostname === the fixed host over https (belt-and-suspenders),
|
|
214
|
+
* and sets `redirect:"error"` (fail closed on any off-host 3xx). Keyless — no
|
|
215
|
+
* header/token.
|
|
216
|
+
*/
|
|
217
|
+
async function getCpsc(params: URLSearchParams): Promise<unknown> {
|
|
218
|
+
const url = `https://${CPSC_HOST}${CPSC_RECALL_PATH}?${params.toString()}`;
|
|
219
|
+
const built = new URL(url);
|
|
220
|
+
if (built.hostname !== CPSC_HOST || built.protocol !== "https:") {
|
|
221
|
+
throw new ToolErrorCarrier({
|
|
222
|
+
kind: "invalid_input",
|
|
223
|
+
retryable: false,
|
|
224
|
+
message: `Constructed CPSC URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(CPSC_HOST)} over https — refusing to fetch (SSRF safety).`,
|
|
225
|
+
upstreamEndpoint: CPSC_RECALL_LABEL,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return getJson(url, { label: CPSC_RECALL_LABEL, redirect: "error" });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Compute an ISO YYYY-MM-DD `days` days before now (the default-window start). */
|
|
232
|
+
function daysAgoIso(days: number): string {
|
|
233
|
+
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Tool: cpsc_recalls ───────────────────────────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Fetch CPSC consumer-product RECALLS → curated recall rows + honest `_meta`.
|
|
239
|
+
* KEYLESS. All filters are optional; with NO filter given, RecallDateStart defaults
|
|
240
|
+
* to ~90 days ago (disclosed in a note) so the whole dataset is never silently
|
|
241
|
+
* fetched. The response is a bare array with no total-count field / no pagination
|
|
242
|
+
* ⇒ totalAvailable = the number of returned recalls, complete:true. An empty array
|
|
243
|
+
* ⇒ an honest empty; a 4xx ⇒ invalid_input; a 5xx/timeout ⇒ THROW; a 200 non-JSON
|
|
244
|
+
* OR a non-array body ⇒ schema_drift.
|
|
245
|
+
*/
|
|
246
|
+
export async function recalls(args: CpscRecallsArgs): Promise<MetaBundle> {
|
|
247
|
+
validateArgs(args);
|
|
248
|
+
|
|
249
|
+
// ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
|
|
250
|
+
// passthrough). format=json is ALWAYS appended. ──
|
|
251
|
+
const params = new URLSearchParams();
|
|
252
|
+
params.set("format", "json");
|
|
253
|
+
const filtersApplied: string[] = [];
|
|
254
|
+
if (args.dateStart !== undefined) {
|
|
255
|
+
params.set("RecallDateStart", args.dateStart);
|
|
256
|
+
filtersApplied.push("dateStart");
|
|
257
|
+
}
|
|
258
|
+
if (args.dateEnd !== undefined) {
|
|
259
|
+
params.set("RecallDateEnd", args.dateEnd);
|
|
260
|
+
filtersApplied.push("dateEnd");
|
|
261
|
+
}
|
|
262
|
+
if (args.productName !== undefined) {
|
|
263
|
+
params.set("ProductName", args.productName);
|
|
264
|
+
filtersApplied.push("productName");
|
|
265
|
+
}
|
|
266
|
+
if (args.manufacturer !== undefined) {
|
|
267
|
+
params.set("Manufacturer", args.manufacturer);
|
|
268
|
+
filtersApplied.push("manufacturer");
|
|
269
|
+
}
|
|
270
|
+
if (args.recallNumber !== undefined) {
|
|
271
|
+
params.set("RecallNumber", args.recallNumber);
|
|
272
|
+
filtersApplied.push("recallNumber");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── DEFAULT-WINDOW: with NO filter, an unbounded fetch would return the WHOLE
|
|
276
|
+
// dataset. Bound it to a ~90-day recent window (RecallDateStart) and DISCLOSE
|
|
277
|
+
// the default — never silently fetch everything. ──
|
|
278
|
+
const notes: string[] = [KEYLESS_NOTE, CPSC_NO_PAGINATION_NOTE];
|
|
279
|
+
let defaultWindowApplied = false;
|
|
280
|
+
if (filtersApplied.length === 0) {
|
|
281
|
+
const defaultStart = daysAgoIso(CPSC_DEFAULT_WINDOW_DAYS);
|
|
282
|
+
params.set("RecallDateStart", defaultStart);
|
|
283
|
+
defaultWindowApplied = true;
|
|
284
|
+
notes.push(
|
|
285
|
+
`No filter was provided — to avoid silently fetching the ENTIRE recall dataset, results are bounded to a default recent window: RecallDateStart=${defaultStart} (~${CPSC_DEFAULT_WINDOW_DAYS} days ago). Pass dateStart/dateEnd, productName, manufacturer, or recallNumber for a scoped query.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── The typed catch-ladder (nhtsa.ts / datagov-catalog.ts shape, VERBATIM):
|
|
290
|
+
// a ToolErrorCarrier (host-assert / 4xx-5xx taxonomy) rethrows FIRST
|
|
291
|
+
// (preserving its kind); a 200 non-JSON `.json()` SyntaxError reclassifies to
|
|
292
|
+
// schema_drift; a bare error rethrows LAST. ──
|
|
293
|
+
let body: unknown;
|
|
294
|
+
try {
|
|
295
|
+
body = await getCpsc(params);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
298
|
+
if (e instanceof SyntaxError)
|
|
299
|
+
throw driftError(
|
|
300
|
+
CPSC_RECALL_LABEL,
|
|
301
|
+
"CPSC /RestWebServices/Recall returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
302
|
+
);
|
|
303
|
+
throw e;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── P4: the top-level body MUST be an array (a non-array object/string/null is
|
|
307
|
+
// drift, never a fabricated empty). ──
|
|
308
|
+
if (!Array.isArray(body)) {
|
|
309
|
+
throw driftError(
|
|
310
|
+
CPSC_RECALL_LABEL,
|
|
311
|
+
"CPSC /RestWebServices/Recall shape drift — the response must be a bare JSON array.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const recalls = (body as unknown[]).map(mapRecall);
|
|
316
|
+
const returned = recalls.length;
|
|
317
|
+
|
|
318
|
+
return withMeta(
|
|
319
|
+
{ recalls },
|
|
320
|
+
{
|
|
321
|
+
source: `${CPSC_HOST} /RestWebServices/Recall (CPSC consumer-product recalls; keyless)`,
|
|
322
|
+
keylessMode: true,
|
|
323
|
+
returned,
|
|
324
|
+
// P1 — no upstream total-count field / no pagination: the complete set IS the
|
|
325
|
+
// returned rows, so totalAvailable = returned and complete:true (derived).
|
|
326
|
+
totalAvailable: returned,
|
|
327
|
+
filtersApplied: defaultWindowApplied ? ["dateStart(default)"] : filtersApplied,
|
|
328
|
+
filtersDropped: [],
|
|
329
|
+
fieldsUnavailable: [],
|
|
330
|
+
notes,
|
|
331
|
+
} satisfies Partial<ResponseMeta>,
|
|
332
|
+
);
|
|
333
|
+
}
|