@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.
Files changed (91) hide show
  1. package/README.ja.md +22 -9
  2. package/README.ko.md +22 -9
  3. package/README.md +70 -12
  4. package/dist/bea.d.ts +105 -0
  5. package/dist/bea.d.ts.map +1 -0
  6. package/dist/bea.js +303 -0
  7. package/dist/bea.js.map +1 -0
  8. package/dist/census-economic.d.ts +1 -1
  9. package/dist/census-economic.d.ts.map +1 -1
  10. package/dist/census-economic.js +12 -6
  11. package/dist/census-economic.js.map +1 -1
  12. package/dist/cms-facility.d.ts +112 -0
  13. package/dist/cms-facility.d.ts.map +1 -0
  14. package/dist/cms-facility.js +311 -0
  15. package/dist/cms-facility.js.map +1 -0
  16. package/dist/cms-hospital.d.ts +105 -0
  17. package/dist/cms-hospital.d.ts.map +1 -0
  18. package/dist/cms-hospital.js +290 -0
  19. package/dist/cms-hospital.js.map +1 -0
  20. package/dist/cms-supplier.d.ts +133 -0
  21. package/dist/cms-supplier.d.ts.map +1 -0
  22. package/dist/cms-supplier.js +414 -0
  23. package/dist/cms-supplier.js.map +1 -0
  24. package/dist/cms-utilization.d.ts +113 -0
  25. package/dist/cms-utilization.d.ts.map +1 -0
  26. package/dist/cms-utilization.js +328 -0
  27. package/dist/cms-utilization.js.map +1 -0
  28. package/dist/courtlistener.d.ts +115 -0
  29. package/dist/courtlistener.d.ts.map +1 -0
  30. package/dist/courtlistener.js +398 -0
  31. package/dist/courtlistener.js.map +1 -0
  32. package/dist/cpsc.d.ts +81 -0
  33. package/dist/cpsc.d.ts.map +1 -0
  34. package/dist/cpsc.js +283 -0
  35. package/dist/cpsc.js.map +1 -0
  36. package/dist/dol.d.ts +118 -0
  37. package/dist/dol.d.ts.map +1 -0
  38. package/dist/dol.js +421 -0
  39. package/dist/dol.js.map +1 -0
  40. package/dist/epa-envirofacts.d.ts +97 -0
  41. package/dist/epa-envirofacts.d.ts.map +1 -0
  42. package/dist/epa-envirofacts.js +292 -0
  43. package/dist/epa-envirofacts.js.map +1 -0
  44. package/dist/fred.d.ts +1 -1
  45. package/dist/fred.js +1 -1
  46. package/dist/keys.d.ts +11 -8
  47. package/dist/keys.d.ts.map +1 -1
  48. package/dist/keys.js +55 -8
  49. package/dist/keys.js.map +1 -1
  50. package/dist/lda.d.ts +105 -0
  51. package/dist/lda.d.ts.map +1 -0
  52. package/dist/lda.js +317 -0
  53. package/dist/lda.js.map +1 -0
  54. package/dist/nhtsa.d.ts +91 -0
  55. package/dist/nhtsa.d.ts.map +1 -0
  56. package/dist/nhtsa.js +263 -0
  57. package/dist/nhtsa.js.map +1 -0
  58. package/dist/nonprofit.d.ts +116 -0
  59. package/dist/nonprofit.d.ts.map +1 -0
  60. package/dist/nonprofit.js +342 -0
  61. package/dist/nonprofit.js.map +1 -0
  62. package/dist/openfda-device.d.ts +85 -0
  63. package/dist/openfda-device.d.ts.map +1 -0
  64. package/dist/openfda-device.js +277 -0
  65. package/dist/openfda-device.js.map +1 -0
  66. package/dist/openfda.d.ts +133 -0
  67. package/dist/openfda.d.ts.map +1 -0
  68. package/dist/openfda.js +402 -0
  69. package/dist/openfda.js.map +1 -0
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +872 -6
  72. package/dist/server.js.map +1 -1
  73. package/package.json +2 -1
  74. package/src/bea.ts +372 -0
  75. package/src/census-economic.ts +12 -6
  76. package/src/cms-facility.ts +379 -0
  77. package/src/cms-hospital.ts +344 -0
  78. package/src/cms-supplier.ts +527 -0
  79. package/src/cms-utilization.ts +389 -0
  80. package/src/courtlistener.ts +465 -0
  81. package/src/cpsc.ts +333 -0
  82. package/src/dol.ts +515 -0
  83. package/src/epa-envirofacts.ts +342 -0
  84. package/src/fred.ts +1 -1
  85. package/src/keys.ts +60 -8
  86. package/src/lda.ts +385 -0
  87. package/src/nhtsa.ts +352 -0
  88. package/src/nonprofit.ts +460 -0
  89. package/src/openfda-device.ts +356 -0
  90. package/src/openfda.ts +495 -0
  91. package/src/server.ts +995 -6
package/src/openfda.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * openfda.ts — openFDA recall/enforcement records (api.fda.gov) — the
3
+ * PRODUCT-SAFETY / RECALL lane (ADR-0054). Drug / device / food recall
4
+ * enforcement reports: the recalling firm, the product, the reason, the FDA
5
+ * classification (Class I/II/III), status, and geography.
6
+ *
7
+ * ★ THIS IS A KEYLESS TOOL WITH AN *OPTIONAL* RATE-LIMIT KEY. openFDA works
8
+ * keyless (~1000 requests/day); a free OPENFDA_API_KEY only RAISES the rate
9
+ * limit. So — unlike Census/FRED/BEA/DOL (key-REQUIRED, throw without it) —
10
+ * this tool NEVER throws for a missing key. When OPENFDA_API_KEY IS set it
11
+ * rides ONLY the `&api_key=` query param (openFDA has NO header option — query
12
+ * only). See the K-test discipline below.
13
+ *
14
+ * GET https://api.fda.gov/{category}/enforcement.json
15
+ * ?search=<lucene>&limit=<1..100>&skip=<offset>[&api_key=<KEY>]
16
+ * category ∈ {drug, device, food}
17
+ * → { meta: { disclaimer, results: { skip, limit, total } }, results: [ {...} ] }
18
+ *
19
+ * This module COPIES (does NOT import) the census-economic.ts bespoke-fetch idiom
20
+ * (a single classified `fetch` rather than the shared getJson) — the reason is
21
+ * the ★P2 CRUX below: a no-match query returns HTTP 404, and getJson→
22
+ * fetchWithRetry throws a `not_found` ToolErrorCarrier that DISCARDS the body, so
23
+ * we could not distinguish a genuine no-match (→ honest empty) from a real 404.
24
+ * A bespoke fetch returns the Response so we can READ the 404 body and reclassify
25
+ * `{error:{code:"NOT_FOUND"}}` → an honest empty. Coercion/meta code is REUSED
26
+ * (`str` coerce.ts null-never-empty-string, `driftError`, `errorFromResponse`,
27
+ * `withMeta`/`buildMeta` with skip/limit offset pagination). NO local str/num.
28
+ *
29
+ * ★ HONESTY (ADR-0054 P1–P5):
30
+ * [P1] totalAvailable = `meta.results.total` EXACT (the REAL total, e.g. drug
31
+ * 17793), NEVER results.length. skip/limit offset pagination:
32
+ * hasMore = skip + returned < total; nextOffset = hasMore ? skip+returned : null.
33
+ * [★P2] a 404 whose body is `{error:{code:"NOT_FOUND"}}` (a no-match query OR an
34
+ * unknown field) ⇒ HONEST EMPTY (returned:0, totalAvailable:0) — NOT thrown,
35
+ * NOT not_found. Any OTHER 4xx (e.g. a 400 syntax error) ⇒ invalid_input
36
+ * surfacing openFDA's error message. 5xx/timeout ⇒ upstream_unavailable
37
+ * THROW. A 200 non-JSON body ⇒ schema_drift. (Reverting the
38
+ * 404-NOT_FOUND-as-empty handling ⇒ RED.)
39
+ * [P3] dates (`recall_initiation_date`, YYYYMMDD) and every scalar surfaced as a
40
+ * STRING via `str` (null-never-empty-string) — no numeric coercion; never
41
+ * fabricated.
42
+ * [P4] `meta.results` or `results` absent / non-array ⇒ driftError (never a
43
+ * fabricated empty).
44
+ * [K-test] OPTIONAL key: when OPENFDA_API_KEY is set it rides `&api_key=` ONLY,
45
+ * so the key WILL appear in the raw fetch URL (an unavoidable openFDA
46
+ * constraint — no header option). Mitigation: the `label` is host+path
47
+ * ONLY (`openfda:/{category}/enforcement`, NO query), so no token can reach
48
+ * ToolError.upstreamEndpoint; `_meta.source` names the MODE only; the key is
49
+ * ABSENT from the serialized {data,_meta}, notes, and any log. Unset ⇒
50
+ * keyless (no api_key param at all).
51
+ * [SSRF] fixed host `api.fda.gov`; `category` is an ENUM (→ the path segment);
52
+ * all filter VALUES are Lucene-escaped + phrase-quoted and ride
53
+ * URLSearchParams `search=`; limit/skip are integers; state is charclass
54
+ * `^[A-Za-z]{2}$`. A post-construction hostname/protocol assertion +
55
+ * `redirect:"error"` lock it (no raw Lucene passthrough — structured only,
56
+ * injection-safe).
57
+ */
58
+
59
+ import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
60
+ import { driftError, isRedirectError } from "./datasource.js";
61
+ import { str } from "./coerce.js";
62
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
63
+
64
+ // ─── SSRF core: the single fixed host ─────────────────────────────
65
+ export const OPENFDA_HOST = "api.fda.gov";
66
+ // The recall/enforcement categories (→ the FIRST path segment). An ENUM, so no
67
+ // free value ever touches the path.
68
+ export const OPENFDA_CATEGORIES = ["drug", "device", "food"] as const;
69
+ export type OpenfdaCategory = (typeof OPENFDA_CATEGORIES)[number];
70
+ const OPENFDA_CATEGORY_SET: ReadonlySet<string> = new Set(OPENFDA_CATEGORIES);
71
+
72
+ // The FDA recall classification enum (surfaced verbatim; a structured filter value).
73
+ export const OPENFDA_CLASSIFICATIONS = ["Class I", "Class II", "Class III"] as const;
74
+ const OPENFDA_CLASSIFICATION_SET: ReadonlySet<string> = new Set(
75
+ OPENFDA_CLASSIFICATIONS,
76
+ );
77
+
78
+ // state filter charclass (a 2-letter US state/territory postal code).
79
+ const STATE_RE = /^[A-Za-z]{2}$/;
80
+
81
+ const DEFAULT_LIMIT = 25;
82
+ const MAX_LIMIT = 100;
83
+
84
+ /** host+path-only label (→ ToolError.upstreamEndpoint); NEVER carries the key. */
85
+ function labelFor(category: string): string {
86
+ return `openfda:/${category}/enforcement`;
87
+ }
88
+
89
+ // ─── Honesty notes (ADR-0054 required set) ────────────────────────
90
+ const NOT_DETERMINATION_NOTE =
91
+ "openFDA recall/enforcement records are FDA-published recall reports; treat classification/status/dates as of the source's last publication. This is reference data, not a live regulatory determination.";
92
+ const KEYLESS_NOTE =
93
+ "Keyless: no OPENFDA_API_KEY is set. openFDA allows ~1000 requests/day without a key; a free key raises the rate limit (get one at https://open.fda.gov/apis/authentication/).";
94
+ const KEYED_NOTE =
95
+ "OPENFDA_API_KEY is set — it rides ONLY the &api_key= query parameter to api.fda.gov (openFDA has no header option), raising the rate limit. Its value is NEVER logged, echoed, or placed in this response.";
96
+ const NO_FILTER_NOTE =
97
+ "No structured filters were applied — this is an unscoped scan of the WHOLE recall/enforcement category. Add firm / product / reason / classification / status / state to scope the result set.";
98
+
99
+ // ─── The OPTIONAL key seam (value NEVER leaked past the &api_key= param) ──
100
+ /** Read OPENFDA_API_KEY from env; trim; return the value or undefined (unset/blank). */
101
+ export function openfdaApiKey(): string | undefined {
102
+ const raw = process.env.OPENFDA_API_KEY;
103
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
104
+ return trimmed ? trimmed : undefined;
105
+ }
106
+
107
+ // ─── Curated recall row shape ─────────────────────────────────────
108
+ export type OpenfdaRecall = {
109
+ recallingFirm: string | null;
110
+ productDescription: string | null;
111
+ reasonForRecall: string | null;
112
+ classification: string | null; // "Class I" / "Class II" / "Class III"
113
+ status: string | null; // Ongoing / Terminated / Completed …
114
+ state: string | null;
115
+ city: string | null;
116
+ recallInitiationDate: string | null; // YYYYMMDD — preserved as a STRING (P3)
117
+ recallNumber: string | null;
118
+ voluntaryMandated: string | null;
119
+ distributionPattern: string | null;
120
+ };
121
+
122
+ /** Map ONE openFDA enforcement result row → the curated shape. Every scalar via `str`. */
123
+ function mapRecall(row: unknown): OpenfdaRecall {
124
+ const r = (row ?? {}) as Record<string, unknown>;
125
+ return {
126
+ recallingFirm: str(r.recalling_firm),
127
+ productDescription: str(r.product_description),
128
+ reasonForRecall: str(r.reason_for_recall),
129
+ classification: str(r.classification),
130
+ status: str(r.status),
131
+ state: str(r.state),
132
+ city: str(r.city),
133
+ recallInitiationDate: str(r.recall_initiation_date),
134
+ recallNumber: str(r.recall_number),
135
+ voluntaryMandated: str(r.voluntary_mandated),
136
+ distributionPattern: str(r.distribution_pattern),
137
+ };
138
+ }
139
+
140
+ // ─── Lucene search assembly (structured-only; injection-safe) ─────
141
+ /**
142
+ * Quote a filter value as a Lucene phrase term. Wrapping in double quotes makes a
143
+ * multi-word value (e.g. "Class I", "johnson & johnson") match as a phrase and
144
+ * closes every operator-injection surface; within the quoted term only `"` and
145
+ * `\` are special, so we backslash-escape BOTH (a `"` in the value can never break
146
+ * out of the quotes). There is NO raw Lucene passthrough — the tool assembles the
147
+ * whole `search=` string from validated typed args.
148
+ */
149
+ export function luceneQuote(v: string): string {
150
+ return '"' + v.replace(/(["\\])/g, "\\$1") + '"';
151
+ }
152
+
153
+ /** The structured filter set → openFDA `field:value` clauses. */
154
+ export type OpenfdaFilters = {
155
+ firm?: string; // → recalling_firm
156
+ product?: string; // → product_description
157
+ reason?: string; // → reason_for_recall
158
+ classification?: string; // → classification (Class I/II/III)
159
+ status?: string; // → status
160
+ state?: string; // → state (2-letter)
161
+ };
162
+
163
+ /**
164
+ * Assemble the openFDA `search=` Lucene string from structured filters — each
165
+ * value Lucene-escaped + phrase-quoted, joined by ` AND `. Returns "" when no
166
+ * filter is present (openFDA then returns the whole category). The mapping of
167
+ * clause → field is FIXED here; a caller can never inject a raw field:value.
168
+ */
169
+ export function buildSearch(f: OpenfdaFilters): string {
170
+ const clauses: string[] = [];
171
+ if (f.firm !== undefined) clauses.push(`recalling_firm:${luceneQuote(f.firm)}`);
172
+ if (f.product !== undefined)
173
+ clauses.push(`product_description:${luceneQuote(f.product)}`);
174
+ if (f.reason !== undefined)
175
+ clauses.push(`reason_for_recall:${luceneQuote(f.reason)}`);
176
+ if (f.classification !== undefined)
177
+ clauses.push(`classification:${luceneQuote(f.classification)}`);
178
+ if (f.status !== undefined) clauses.push(`status:${luceneQuote(f.status)}`);
179
+ if (f.state !== undefined)
180
+ clauses.push(`state:${luceneQuote(f.state.toUpperCase())}`);
181
+ return clauses.join(" AND ");
182
+ }
183
+
184
+ // ─── Bespoke SSRF-guarded fetch (fixed host + assert + redirect:"error") ──
185
+ /**
186
+ * A SINGLE classified `fetch` to api.fda.gov (NOT the shared getJson — we must
187
+ * READ a 404 body to distinguish a NOT_FOUND no-match from a real outage; see the
188
+ * ★P2 crux in the caller). Builds the URL on the FIXED host from `params`, asserts
189
+ * the constructed URL cannot have been steered off-host (belt-and-suspenders), and
190
+ * sets `redirect:"error"` (fail closed on any off-host 3xx — a redirect could
191
+ * carry the api_key away). Returns the raw Response for the caller to classify. A
192
+ * timeout/abort ⇒ non-retryable upstream_unavailable; a network TypeError ⇒
193
+ * retryable upstream_unavailable; a redirect TypeError ⇒ schema_drift (never a
194
+ * fake-empty). `label` is host+path only.
195
+ */
196
+ export async function fetchOpenfda(url: string, label: string): Promise<Response> {
197
+ const built = new URL(url);
198
+ if (built.hostname !== OPENFDA_HOST || built.protocol !== "https:") {
199
+ throw new ToolErrorCarrier({
200
+ kind: "invalid_input",
201
+ retryable: false,
202
+ message: `Constructed openFDA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${OPENFDA_HOST} over https — refusing to fetch (SSRF safety).`,
203
+ upstreamEndpoint: label,
204
+ });
205
+ }
206
+ try {
207
+ return await fetch(built.toString(), {
208
+ redirect: "error",
209
+ signal: AbortSignal.timeout(15_000),
210
+ });
211
+ } catch (e) {
212
+ if (isRedirectError(e)) {
213
+ throw driftError(
214
+ label,
215
+ `openFDA returned an off-host redirect (redirect:"error") while fetching ${label} — refusing to follow it (SSRF safety).`,
216
+ );
217
+ }
218
+ if (
219
+ e instanceof Error &&
220
+ (e.name === "TimeoutError" || e.name === "AbortError")
221
+ ) {
222
+ throw new ToolErrorCarrier({
223
+ kind: "upstream_unavailable",
224
+ message: `Request to ${label} timed out.`,
225
+ retryable: false,
226
+ upstreamEndpoint: label,
227
+ });
228
+ }
229
+ throw new ToolErrorCarrier({
230
+ kind: "upstream_unavailable",
231
+ message: `Network error reaching ${label}: ${e instanceof Error ? e.message : String(e)}`,
232
+ retryable: true,
233
+ retryAfterSeconds: 30,
234
+ upstreamEndpoint: label,
235
+ });
236
+ }
237
+ }
238
+
239
+ /** Best-effort read of an openFDA error body `{error:{code,message}}`. null on any failure. */
240
+ export async function readOpenfdaError(
241
+ res: Response,
242
+ ): Promise<{ code: string | null; message: string | null }> {
243
+ try {
244
+ const body = (await res.json()) as {
245
+ error?: { code?: unknown; message?: unknown };
246
+ };
247
+ return {
248
+ code: str(body?.error?.code),
249
+ message: str(body?.error?.message),
250
+ };
251
+ } catch {
252
+ return { code: null, message: null };
253
+ }
254
+ }
255
+
256
+ // ─── Tool: openfda_enforcement ────────────────────────────────────
257
+ export type OpenfdaEnforcementArgs = OpenfdaFilters & {
258
+ category?: string; // drug | device | food (default drug)
259
+ limit?: number; // 1..100 (default 25)
260
+ skip?: number; // offset ≥ 0 (default 0)
261
+ };
262
+
263
+ /**
264
+ * Search openFDA recall/enforcement records for a category (drug/device/food) with
265
+ * structured filters → curated recall rows + honest `_meta`. KEYLESS (an OPTIONAL
266
+ * OPENFDA_API_KEY only raises the rate limit). totalAvailable = meta.results.total
267
+ * (EXACT); skip/limit offset pagination. ★A no-match query (openFDA HTTP 404
268
+ * NOT_FOUND) ⇒ an honest empty, never a throw.
269
+ */
270
+ export async function enforcement(
271
+ args: OpenfdaEnforcementArgs,
272
+ ): Promise<MetaBundle> {
273
+ // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
274
+ // a DIRECT handler call bypasses Zod). ──
275
+ const category = args.category ?? "drug";
276
+ const label = labelFor(category);
277
+ if (!OPENFDA_CATEGORY_SET.has(category)) {
278
+ throw new ToolErrorCarrier({
279
+ kind: "invalid_input",
280
+ retryable: false,
281
+ message: `Invalid category ${JSON.stringify(category)} — expected one of ${OPENFDA_CATEGORIES.join(", ")} (it rides in the request PATH; strictly validated).`,
282
+ upstreamEndpoint: label,
283
+ });
284
+ }
285
+ if (
286
+ args.classification !== undefined &&
287
+ !OPENFDA_CLASSIFICATION_SET.has(args.classification)
288
+ ) {
289
+ throw new ToolErrorCarrier({
290
+ kind: "invalid_input",
291
+ retryable: false,
292
+ message: `Invalid classification ${JSON.stringify(args.classification)} — expected one of ${OPENFDA_CLASSIFICATIONS.join(", ")}.`,
293
+ upstreamEndpoint: label,
294
+ });
295
+ }
296
+ if (args.state !== undefined && !STATE_RE.test(args.state)) {
297
+ throw new ToolErrorCarrier({
298
+ kind: "invalid_input",
299
+ retryable: false,
300
+ message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-letter US state/territory postal code (^[A-Za-z]{2}$), e.g. "CA".`,
301
+ upstreamEndpoint: label,
302
+ });
303
+ }
304
+ const limit = clampLimit(args.limit);
305
+ const skip = clampSkip(args.skip);
306
+
307
+ // ── Assemble the query (structured-only; all VALUES via URLSearchParams — no
308
+ // host/path steer). The OPTIONAL key rides ONLY here in &api_key=. ──
309
+ const filters: OpenfdaFilters = {
310
+ firm: args.firm,
311
+ product: args.product,
312
+ reason: args.reason,
313
+ classification: args.classification,
314
+ status: args.status,
315
+ state: args.state,
316
+ };
317
+ const search = buildSearch(filters);
318
+ const filtersApplied: string[] = [];
319
+ if (args.firm !== undefined) filtersApplied.push("firm");
320
+ if (args.product !== undefined) filtersApplied.push("product");
321
+ if (args.reason !== undefined) filtersApplied.push("reason");
322
+ if (args.classification !== undefined) filtersApplied.push("classification");
323
+ if (args.status !== undefined) filtersApplied.push("status");
324
+ if (args.state !== undefined) filtersApplied.push("state");
325
+
326
+ const params = new URLSearchParams();
327
+ if (search !== "") params.set("search", search);
328
+ params.set("limit", String(limit));
329
+ params.set("skip", String(skip));
330
+ const key = openfdaApiKey();
331
+ if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
332
+
333
+ const url = `https://${OPENFDA_HOST}/${category}/enforcement.json?${params.toString()}`;
334
+
335
+ // ── Fetch + classify. ★P2 CRUX: a 404 whose body is {error:{code:"NOT_FOUND"}}
336
+ // is a genuine no-match (or an unknown field) ⇒ an HONEST EMPTY, never a
337
+ // thrown not_found. Any OTHER 4xx (e.g. 400 syntax) ⇒ invalid_input surfacing
338
+ // openFDA's message; a 5xx/429 ⇒ the shared taxonomy THROWS. ──
339
+ const res = await fetchOpenfda(url, label);
340
+
341
+ if (res.status === 404) {
342
+ const { code, message } = await readOpenfdaError(res);
343
+ if (code === "NOT_FOUND") {
344
+ // Honest empty — NOT thrown, NOT not_found (the openFDA no-match idiom).
345
+ return emptyResult(category, limit, skip, filtersApplied, key !== undefined);
346
+ }
347
+ // A non-NOT_FOUND 404 ⇒ the shared not_found taxonomy (never a fake-empty).
348
+ throw new ToolErrorCarrier({
349
+ ...errorFromResponse(res, label),
350
+ message: message
351
+ ? `openFDA returned HTTP 404 at ${label}: ${message}`
352
+ : `Resource not found at ${label} (HTTP 404).`,
353
+ });
354
+ }
355
+
356
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
357
+ // A 4xx OTHER than 404/429 (e.g. 400 syntax) ⇒ invalid_input surfacing the
358
+ // openFDA error message (a caller-fixable request, never a fake-empty).
359
+ const { message } = await readOpenfdaError(res);
360
+ throw new ToolErrorCarrier({
361
+ kind: "invalid_input",
362
+ retryable: false,
363
+ message: message
364
+ ? `openFDA rejected the request (HTTP ${res.status}) at ${label}: ${message}`
365
+ : `Bad request (HTTP ${res.status}) at ${label}.`,
366
+ upstreamStatus: res.status,
367
+ upstreamEndpoint: label,
368
+ });
369
+ }
370
+
371
+ if (!res.ok) {
372
+ // 429 → rate_limited; 5xx → upstream_unavailable (a DOWN service is NEVER an
373
+ // empty result). Delegated to the shared errors.ts taxonomy.
374
+ throw new ToolErrorCarrier(errorFromResponse(res, label));
375
+ }
376
+
377
+ // ── 200 ⇒ parse JSON; a non-JSON body ⇒ r.json() SyntaxError ⇒ schema_drift
378
+ // (never read as an empty result). ──
379
+ let body: unknown;
380
+ try {
381
+ body = await res.json();
382
+ } catch (e) {
383
+ if (e instanceof SyntaxError) {
384
+ throw driftError(
385
+ label,
386
+ `openFDA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
387
+ );
388
+ }
389
+ throw e;
390
+ }
391
+
392
+ // ── [P4] meta.results (the total carrier) and results (the rows) MUST be present
393
+ // + well-shaped; anything else is drift, never a fabricated empty. ──
394
+ const b = (body ?? {}) as { meta?: unknown; results?: unknown };
395
+ const meta = (b.meta ?? {}) as { results?: unknown };
396
+ const metaResults = meta.results as { total?: unknown } | undefined;
397
+ if (
398
+ metaResults === undefined ||
399
+ metaResults === null ||
400
+ typeof metaResults !== "object"
401
+ ) {
402
+ throw driftError(
403
+ label,
404
+ `openFDA ${label} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
405
+ );
406
+ }
407
+ if (!Array.isArray(b.results)) {
408
+ throw driftError(
409
+ label,
410
+ `openFDA ${label} shape drift — results must be an array.`,
411
+ );
412
+ }
413
+
414
+ const recalls = (b.results as unknown[]).map(mapRecall);
415
+ const returned = recalls.length;
416
+
417
+ // ── [P1] totalAvailable = meta.results.total EXACT (the REAL total), NEVER
418
+ // results.length. skip/limit offset pagination. ──
419
+ const rawTotal = metaResults.total;
420
+ const totalAvailable =
421
+ typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
422
+ const hasMore =
423
+ totalAvailable !== null && skip + returned < totalAvailable;
424
+ const nextOffset = hasMore ? skip + returned : null;
425
+
426
+ const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
427
+ if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
428
+
429
+ return withMeta(
430
+ { recalls },
431
+ {
432
+ // MODE only — never the key value (K-test).
433
+ source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
434
+ key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
435
+ })`,
436
+ keylessMode: true, // a keyless tool; the optional key only raises the rate limit
437
+ returned,
438
+ totalAvailable,
439
+ filtersApplied,
440
+ filtersDropped: [],
441
+ fieldsUnavailable: [],
442
+ pagination: { offset: skip, limit, hasMore, nextOffset },
443
+ notes,
444
+ } satisfies Partial<ResponseMeta>,
445
+ );
446
+ }
447
+
448
+ // ─── Small helpers ────────────────────────────────────────────────
449
+ function keyNote(hasKey: boolean): string {
450
+ return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
451
+ }
452
+
453
+ /** An honest empty result (★P2: a 404 NOT_FOUND no-match) — returned:0, total:0. */
454
+ function emptyResult(
455
+ category: string,
456
+ limit: number,
457
+ skip: number,
458
+ filtersApplied: string[],
459
+ hasKey: boolean,
460
+ ): MetaBundle {
461
+ return withMeta(
462
+ { recalls: [] as OpenfdaRecall[] },
463
+ {
464
+ source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
465
+ hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
466
+ })`,
467
+ keylessMode: true,
468
+ returned: 0,
469
+ totalAvailable: 0,
470
+ filtersApplied,
471
+ filtersDropped: [],
472
+ fieldsUnavailable: [],
473
+ pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
474
+ notes: [
475
+ "No recall/enforcement records matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
476
+ NOT_DETERMINATION_NOTE,
477
+ keyNote(hasKey),
478
+ ],
479
+ } satisfies Partial<ResponseMeta>,
480
+ );
481
+ }
482
+
483
+ function clampLimit(v: unknown): number {
484
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
485
+ const n = Math.floor(v);
486
+ if (n < 1) return 1;
487
+ if (n > MAX_LIMIT) return MAX_LIMIT;
488
+ return n;
489
+ }
490
+
491
+ function clampSkip(v: unknown): number {
492
+ if (typeof v !== "number" || !Number.isFinite(v)) return 0;
493
+ const n = Math.floor(v);
494
+ return n < 0 ? 0 : n;
495
+ }