@cliwant/mcp-sam-gov 1.5.0 → 1.7.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
package/src/openfda.ts CHANGED
@@ -1,518 +1,518 @@
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
- // dogfooding 2026-07-16: `category` defaults to 'drug'. A DEVICE- or FOOD-intent
99
- // caller who omits it silently searches the DRUG dataset (a separate openFDA index
100
- // with its own, much smaller total — e.g. "pacemaker" returns 1 drug recall vs 197
101
- // device recalls). The default is convenient but load-bearing, so when it is TAKEN
102
- // (not explicitly chosen) we say so and point to the alternatives. `category` is
103
- // also echoed in filtersApplied so the drug/device/food choice is never invisible.
104
- const CATEGORY_DEFAULT_NOTE =
105
- "category was NOT specified and DEFAULTED to 'drug' — this searched the DRUG recall dataset (/drug/enforcement) ONLY. For medical DEVICES pass category:'device'; for FOOD pass category:'food'. Each category is a SEPARATE openFDA dataset with its own total, so a device/food-intent query left on the default MISSES all of those recalls.";
106
-
107
- // ─── The OPTIONAL key seam (value NEVER leaked past the &api_key= param) ──
108
- /** Read OPENFDA_API_KEY from env; trim; return the value or undefined (unset/blank). */
109
- export function openfdaApiKey(): string | undefined {
110
- const raw = process.env.OPENFDA_API_KEY;
111
- const trimmed = typeof raw === "string" ? raw.trim() : "";
112
- return trimmed ? trimmed : undefined;
113
- }
114
-
115
- // ─── Curated recall row shape ─────────────────────────────────────
116
- export type OpenfdaRecall = {
117
- recallingFirm: string | null;
118
- productDescription: string | null;
119
- reasonForRecall: string | null;
120
- classification: string | null; // "Class I" / "Class II" / "Class III"
121
- status: string | null; // Ongoing / Terminated / Completed …
122
- state: string | null;
123
- city: string | null;
124
- recallInitiationDate: string | null; // YYYYMMDD — preserved as a STRING (P3)
125
- recallNumber: string | null;
126
- voluntaryMandated: string | null;
127
- distributionPattern: string | null;
128
- };
129
-
130
- /** Map ONE openFDA enforcement result row → the curated shape. Every scalar via `str`. */
131
- function mapRecall(row: unknown): OpenfdaRecall {
132
- const r = (row ?? {}) as Record<string, unknown>;
133
- return {
134
- recallingFirm: str(r.recalling_firm),
135
- productDescription: str(r.product_description),
136
- reasonForRecall: str(r.reason_for_recall),
137
- classification: str(r.classification),
138
- status: str(r.status),
139
- state: str(r.state),
140
- city: str(r.city),
141
- recallInitiationDate: str(r.recall_initiation_date),
142
- recallNumber: str(r.recall_number),
143
- voluntaryMandated: str(r.voluntary_mandated),
144
- distributionPattern: str(r.distribution_pattern),
145
- };
146
- }
147
-
148
- // ─── Lucene search assembly (structured-only; injection-safe) ─────
149
- /**
150
- * Quote a filter value as a Lucene phrase term. Wrapping in double quotes makes a
151
- * multi-word value (e.g. "Class I", "johnson & johnson") match as a phrase and
152
- * closes every operator-injection surface; within the quoted term only `"` and
153
- * `\` are special, so we backslash-escape BOTH (a `"` in the value can never break
154
- * out of the quotes). There is NO raw Lucene passthrough — the tool assembles the
155
- * whole `search=` string from validated typed args.
156
- */
157
- export function luceneQuote(v: string): string {
158
- return '"' + v.replace(/(["\\])/g, "\\$1") + '"';
159
- }
160
-
161
- /** The structured filter set → openFDA `field:value` clauses. */
162
- export type OpenfdaFilters = {
163
- firm?: string; // → recalling_firm
164
- product?: string; // → product_description
165
- reason?: string; // → reason_for_recall
166
- classification?: string; // → classification (Class I/II/III)
167
- status?: string; // → status
168
- state?: string; // → state (2-letter)
169
- };
170
-
171
- /**
172
- * Assemble the openFDA `search=` Lucene string from structured filters — each
173
- * value Lucene-escaped + phrase-quoted, joined by ` AND `. Returns "" when no
174
- * filter is present (openFDA then returns the whole category). The mapping of
175
- * clause → field is FIXED here; a caller can never inject a raw field:value.
176
- */
177
- export function buildSearch(f: OpenfdaFilters): string {
178
- const clauses: string[] = [];
179
- if (f.firm !== undefined) clauses.push(`recalling_firm:${luceneQuote(f.firm)}`);
180
- if (f.product !== undefined)
181
- clauses.push(`product_description:${luceneQuote(f.product)}`);
182
- if (f.reason !== undefined)
183
- clauses.push(`reason_for_recall:${luceneQuote(f.reason)}`);
184
- if (f.classification !== undefined)
185
- clauses.push(`classification:${luceneQuote(f.classification)}`);
186
- if (f.status !== undefined) clauses.push(`status:${luceneQuote(f.status)}`);
187
- if (f.state !== undefined)
188
- clauses.push(`state:${luceneQuote(f.state.toUpperCase())}`);
189
- return clauses.join(" AND ");
190
- }
191
-
192
- // ─── Bespoke SSRF-guarded fetch (fixed host + assert + redirect:"error") ──
193
- /**
194
- * A SINGLE classified `fetch` to api.fda.gov (NOT the shared getJson — we must
195
- * READ a 404 body to distinguish a NOT_FOUND no-match from a real outage; see the
196
- * ★P2 crux in the caller). Builds the URL on the FIXED host from `params`, asserts
197
- * the constructed URL cannot have been steered off-host (belt-and-suspenders), and
198
- * sets `redirect:"error"` (fail closed on any off-host 3xx — a redirect could
199
- * carry the api_key away). Returns the raw Response for the caller to classify. A
200
- * timeout/abort ⇒ non-retryable upstream_unavailable; a network TypeError ⇒
201
- * retryable upstream_unavailable; a redirect TypeError ⇒ schema_drift (never a
202
- * fake-empty). `label` is host+path only.
203
- */
204
- export async function fetchOpenfda(url: string, label: string): Promise<Response> {
205
- const built = new URL(url);
206
- if (built.hostname !== OPENFDA_HOST || built.protocol !== "https:") {
207
- throw new ToolErrorCarrier({
208
- kind: "invalid_input",
209
- retryable: false,
210
- message: `Constructed openFDA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${OPENFDA_HOST} over https — refusing to fetch (SSRF safety).`,
211
- upstreamEndpoint: label,
212
- });
213
- }
214
- try {
215
- return await fetch(built.toString(), {
216
- redirect: "error",
217
- signal: AbortSignal.timeout(15_000),
218
- });
219
- } catch (e) {
220
- if (isRedirectError(e)) {
221
- throw driftError(
222
- label,
223
- `openFDA returned an off-host redirect (redirect:"error") while fetching ${label} — refusing to follow it (SSRF safety).`,
224
- );
225
- }
226
- if (
227
- e instanceof Error &&
228
- (e.name === "TimeoutError" || e.name === "AbortError")
229
- ) {
230
- throw new ToolErrorCarrier({
231
- kind: "upstream_unavailable",
232
- message: `Request to ${label} timed out.`,
233
- retryable: false,
234
- upstreamEndpoint: label,
235
- });
236
- }
237
- throw new ToolErrorCarrier({
238
- kind: "upstream_unavailable",
239
- message: `Network error reaching ${label}: ${e instanceof Error ? e.message : String(e)}`,
240
- retryable: true,
241
- retryAfterSeconds: 30,
242
- upstreamEndpoint: label,
243
- });
244
- }
245
- }
246
-
247
- /** Best-effort read of an openFDA error body `{error:{code,message}}`. null on any failure. */
248
- export async function readOpenfdaError(
249
- res: Response,
250
- ): Promise<{ code: string | null; message: string | null }> {
251
- try {
252
- const body = (await res.json()) as {
253
- error?: { code?: unknown; message?: unknown };
254
- };
255
- return {
256
- code: str(body?.error?.code),
257
- message: str(body?.error?.message),
258
- };
259
- } catch {
260
- return { code: null, message: null };
261
- }
262
- }
263
-
264
- // ─── Tool: openfda_enforcement ────────────────────────────────────
265
- export type OpenfdaEnforcementArgs = OpenfdaFilters & {
266
- category?: string; // drug | device | food (default drug)
267
- limit?: number; // 1..100 (default 25)
268
- skip?: number; // offset ≥ 0 (default 0)
269
- };
270
-
271
- /**
272
- * Search openFDA recall/enforcement records for a category (drug/device/food) with
273
- * structured filters → curated recall rows + honest `_meta`. KEYLESS (an OPTIONAL
274
- * OPENFDA_API_KEY only raises the rate limit). totalAvailable = meta.results.total
275
- * (EXACT); skip/limit offset pagination. ★A no-match query (openFDA HTTP 404
276
- * NOT_FOUND) ⇒ an honest empty, never a throw.
277
- */
278
- export async function enforcement(
279
- args: OpenfdaEnforcementArgs,
280
- ): Promise<MetaBundle> {
281
- // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
282
- // a DIRECT handler call bypasses Zod). ──
283
- const category = args.category ?? "drug";
284
- const label = labelFor(category);
285
- if (!OPENFDA_CATEGORY_SET.has(category)) {
286
- throw new ToolErrorCarrier({
287
- kind: "invalid_input",
288
- retryable: false,
289
- message: `Invalid category ${JSON.stringify(category)} — expected one of ${OPENFDA_CATEGORIES.join(", ")} (it rides in the request PATH; strictly validated).`,
290
- upstreamEndpoint: label,
291
- });
292
- }
293
- if (
294
- args.classification !== undefined &&
295
- !OPENFDA_CLASSIFICATION_SET.has(args.classification)
296
- ) {
297
- throw new ToolErrorCarrier({
298
- kind: "invalid_input",
299
- retryable: false,
300
- message: `Invalid classification ${JSON.stringify(args.classification)} — expected one of ${OPENFDA_CLASSIFICATIONS.join(", ")}.`,
301
- upstreamEndpoint: label,
302
- });
303
- }
304
- if (args.state !== undefined && !STATE_RE.test(args.state)) {
305
- throw new ToolErrorCarrier({
306
- kind: "invalid_input",
307
- retryable: false,
308
- message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-letter US state/territory postal code (^[A-Za-z]{2}$), e.g. "CA".`,
309
- upstreamEndpoint: label,
310
- });
311
- }
312
- const limit = clampLimit(args.limit);
313
- const skip = clampSkip(args.skip);
314
-
315
- // ── Assemble the query (structured-only; all VALUES via URLSearchParams — no
316
- // host/path steer). The OPTIONAL key rides ONLY here in &api_key=. ──
317
- const filters: OpenfdaFilters = {
318
- firm: args.firm,
319
- product: args.product,
320
- reason: args.reason,
321
- classification: args.classification,
322
- status: args.status,
323
- state: args.state,
324
- };
325
- const search = buildSearch(filters);
326
- const filtersApplied: string[] = [];
327
- if (args.firm !== undefined) filtersApplied.push("firm");
328
- if (args.product !== undefined) filtersApplied.push("product");
329
- if (args.reason !== undefined) filtersApplied.push("reason");
330
- if (args.classification !== undefined) filtersApplied.push("classification");
331
- if (args.status !== undefined) filtersApplied.push("status");
332
- if (args.state !== undefined) filtersApplied.push("state");
333
- // NO_FILTER_NOTE gates on the STRUCTURED filters only — capture that BEFORE the
334
- // always-present category selector is appended below.
335
- const hasStructuredFilter = filtersApplied.length > 0;
336
- // The category (drug|device|food) is the ENDPOINT selector — always applied and
337
- // consequential — so echo it in filtersApplied to make the choice VISIBLE (a
338
- // device query left on the drug default must not look identical to a device query).
339
- const categoryDefaulted = args.category === undefined;
340
- filtersApplied.push(`category:${category}`);
341
-
342
- const params = new URLSearchParams();
343
- if (search !== "") params.set("search", search);
344
- params.set("limit", String(limit));
345
- params.set("skip", String(skip));
346
- const key = openfdaApiKey();
347
- if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
348
-
349
- const url = `https://${OPENFDA_HOST}/${category}/enforcement.json?${params.toString()}`;
350
-
351
- // ── Fetch + classify. ★P2 CRUX: a 404 whose body is {error:{code:"NOT_FOUND"}}
352
- // is a genuine no-match (or an unknown field) ⇒ an HONEST EMPTY, never a
353
- // thrown not_found. Any OTHER 4xx (e.g. 400 syntax) ⇒ invalid_input surfacing
354
- // openFDA's message; a 5xx/429 ⇒ the shared taxonomy THROWS. ──
355
- const res = await fetchOpenfda(url, label);
356
-
357
- if (res.status === 404) {
358
- const { code, message } = await readOpenfdaError(res);
359
- if (code === "NOT_FOUND") {
360
- // Honest empty — NOT thrown, NOT not_found (the openFDA no-match idiom).
361
- return emptyResult(category, limit, skip, filtersApplied, key !== undefined, categoryDefaulted);
362
- }
363
- // A non-NOT_FOUND 404 ⇒ the shared not_found taxonomy (never a fake-empty).
364
- throw new ToolErrorCarrier({
365
- ...errorFromResponse(res, label),
366
- message: message
367
- ? `openFDA returned HTTP 404 at ${label}: ${message}`
368
- : `Resource not found at ${label} (HTTP 404).`,
369
- });
370
- }
371
-
372
- if (res.status >= 400 && res.status < 500 && res.status !== 429) {
373
- // A 4xx OTHER than 404/429 (e.g. 400 syntax) ⇒ invalid_input surfacing the
374
- // openFDA error message (a caller-fixable request, never a fake-empty).
375
- const { message } = await readOpenfdaError(res);
376
- throw new ToolErrorCarrier({
377
- kind: "invalid_input",
378
- retryable: false,
379
- message: message
380
- ? `openFDA rejected the request (HTTP ${res.status}) at ${label}: ${message}`
381
- : `Bad request (HTTP ${res.status}) at ${label}.`,
382
- upstreamStatus: res.status,
383
- upstreamEndpoint: label,
384
- });
385
- }
386
-
387
- if (!res.ok) {
388
- // 429 → rate_limited; 5xx → upstream_unavailable (a DOWN service is NEVER an
389
- // empty result). Delegated to the shared errors.ts taxonomy.
390
- throw new ToolErrorCarrier(errorFromResponse(res, label));
391
- }
392
-
393
- // ── 200 ⇒ parse JSON; a non-JSON body ⇒ r.json() SyntaxError ⇒ schema_drift
394
- // (never read as an empty result). ──
395
- let body: unknown;
396
- try {
397
- body = await res.json();
398
- } catch (e) {
399
- if (e instanceof SyntaxError) {
400
- throw driftError(
401
- label,
402
- `openFDA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
403
- );
404
- }
405
- throw e;
406
- }
407
-
408
- // ── [P4] meta.results (the total carrier) and results (the rows) MUST be present
409
- // + well-shaped; anything else is drift, never a fabricated empty. ──
410
- const b = (body ?? {}) as { meta?: unknown; results?: unknown };
411
- const meta = (b.meta ?? {}) as { results?: unknown };
412
- const metaResults = meta.results as { total?: unknown } | undefined;
413
- if (
414
- metaResults === undefined ||
415
- metaResults === null ||
416
- typeof metaResults !== "object"
417
- ) {
418
- throw driftError(
419
- label,
420
- `openFDA ${label} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
421
- );
422
- }
423
- if (!Array.isArray(b.results)) {
424
- throw driftError(
425
- label,
426
- `openFDA ${label} shape drift — results must be an array.`,
427
- );
428
- }
429
-
430
- const recalls = (b.results as unknown[]).map(mapRecall);
431
- const returned = recalls.length;
432
-
433
- // ── [P1] totalAvailable = meta.results.total EXACT (the REAL total), NEVER
434
- // results.length. skip/limit offset pagination. ──
435
- const rawTotal = metaResults.total;
436
- const totalAvailable =
437
- typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
438
- const hasMore =
439
- totalAvailable !== null && skip + returned < totalAvailable;
440
- const nextOffset = hasMore ? skip + returned : null;
441
-
442
- const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
443
- if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
444
- if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
445
-
446
- return withMeta(
447
- { recalls },
448
- {
449
- // MODE only — never the key value (K-test).
450
- source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
451
- key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
452
- })`,
453
- keylessMode: true, // a keyless tool; the optional key only raises the rate limit
454
- returned,
455
- totalAvailable,
456
- filtersApplied,
457
- filtersDropped: [],
458
- fieldsUnavailable: [],
459
- pagination: { offset: skip, limit, hasMore, nextOffset },
460
- notes,
461
- } satisfies Partial<ResponseMeta>,
462
- );
463
- }
464
-
465
- // ─── Small helpers ────────────────────────────────────────────────
466
- function keyNote(hasKey: boolean): string {
467
- return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
468
- }
469
-
470
- /** An honest empty result (★P2: a 404 NOT_FOUND no-match) — returned:0, total:0. */
471
- function emptyResult(
472
- category: string,
473
- limit: number,
474
- skip: number,
475
- filtersApplied: string[],
476
- hasKey: boolean,
477
- categoryDefaulted: boolean,
478
- ): MetaBundle {
479
- const notes = [
480
- "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.",
481
- NOT_DETERMINATION_NOTE,
482
- keyNote(hasKey),
483
- ];
484
- // A defaulted category on an EMPTY result is the most misleading case — a device
485
- // analyst reads "0 recalls" as "clean" when they actually searched the wrong
486
- // dataset. Surface the default + the alternatives.
487
- if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
488
- return withMeta(
489
- { recalls: [] as OpenfdaRecall[] },
490
- {
491
- source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
492
- hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
493
- })`,
494
- keylessMode: true,
495
- returned: 0,
496
- totalAvailable: 0,
497
- filtersApplied,
498
- filtersDropped: [],
499
- fieldsUnavailable: [],
500
- pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
501
- notes,
502
- } satisfies Partial<ResponseMeta>,
503
- );
504
- }
505
-
506
- function clampLimit(v: unknown): number {
507
- if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
508
- const n = Math.floor(v);
509
- if (n < 1) return 1;
510
- if (n > MAX_LIMIT) return MAX_LIMIT;
511
- return n;
512
- }
513
-
514
- function clampSkip(v: unknown): number {
515
- if (typeof v !== "number" || !Number.isFinite(v)) return 0;
516
- const n = Math.floor(v);
517
- return n < 0 ? 0 : n;
518
- }
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
+ // dogfooding 2026-07-16: `category` defaults to 'drug'. A DEVICE- or FOOD-intent
99
+ // caller who omits it silently searches the DRUG dataset (a separate openFDA index
100
+ // with its own, much smaller total — e.g. "pacemaker" returns 1 drug recall vs 197
101
+ // device recalls). The default is convenient but load-bearing, so when it is TAKEN
102
+ // (not explicitly chosen) we say so and point to the alternatives. `category` is
103
+ // also echoed in filtersApplied so the drug/device/food choice is never invisible.
104
+ const CATEGORY_DEFAULT_NOTE =
105
+ "category was NOT specified and DEFAULTED to 'drug' — this searched the DRUG recall dataset (/drug/enforcement) ONLY. For medical DEVICES pass category:'device'; for FOOD pass category:'food'. Each category is a SEPARATE openFDA dataset with its own total, so a device/food-intent query left on the default MISSES all of those recalls.";
106
+
107
+ // ─── The OPTIONAL key seam (value NEVER leaked past the &api_key= param) ──
108
+ /** Read OPENFDA_API_KEY from env; trim; return the value or undefined (unset/blank). */
109
+ export function openfdaApiKey(): string | undefined {
110
+ const raw = process.env.OPENFDA_API_KEY;
111
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
112
+ return trimmed ? trimmed : undefined;
113
+ }
114
+
115
+ // ─── Curated recall row shape ─────────────────────────────────────
116
+ export type OpenfdaRecall = {
117
+ recallingFirm: string | null;
118
+ productDescription: string | null;
119
+ reasonForRecall: string | null;
120
+ classification: string | null; // "Class I" / "Class II" / "Class III"
121
+ status: string | null; // Ongoing / Terminated / Completed …
122
+ state: string | null;
123
+ city: string | null;
124
+ recallInitiationDate: string | null; // YYYYMMDD — preserved as a STRING (P3)
125
+ recallNumber: string | null;
126
+ voluntaryMandated: string | null;
127
+ distributionPattern: string | null;
128
+ };
129
+
130
+ /** Map ONE openFDA enforcement result row → the curated shape. Every scalar via `str`. */
131
+ function mapRecall(row: unknown): OpenfdaRecall {
132
+ const r = (row ?? {}) as Record<string, unknown>;
133
+ return {
134
+ recallingFirm: str(r.recalling_firm),
135
+ productDescription: str(r.product_description),
136
+ reasonForRecall: str(r.reason_for_recall),
137
+ classification: str(r.classification),
138
+ status: str(r.status),
139
+ state: str(r.state),
140
+ city: str(r.city),
141
+ recallInitiationDate: str(r.recall_initiation_date),
142
+ recallNumber: str(r.recall_number),
143
+ voluntaryMandated: str(r.voluntary_mandated),
144
+ distributionPattern: str(r.distribution_pattern),
145
+ };
146
+ }
147
+
148
+ // ─── Lucene search assembly (structured-only; injection-safe) ─────
149
+ /**
150
+ * Quote a filter value as a Lucene phrase term. Wrapping in double quotes makes a
151
+ * multi-word value (e.g. "Class I", "johnson & johnson") match as a phrase and
152
+ * closes every operator-injection surface; within the quoted term only `"` and
153
+ * `\` are special, so we backslash-escape BOTH (a `"` in the value can never break
154
+ * out of the quotes). There is NO raw Lucene passthrough — the tool assembles the
155
+ * whole `search=` string from validated typed args.
156
+ */
157
+ export function luceneQuote(v: string): string {
158
+ return '"' + v.replace(/(["\\])/g, "\\$1") + '"';
159
+ }
160
+
161
+ /** The structured filter set → openFDA `field:value` clauses. */
162
+ export type OpenfdaFilters = {
163
+ firm?: string; // → recalling_firm
164
+ product?: string; // → product_description
165
+ reason?: string; // → reason_for_recall
166
+ classification?: string; // → classification (Class I/II/III)
167
+ status?: string; // → status
168
+ state?: string; // → state (2-letter)
169
+ };
170
+
171
+ /**
172
+ * Assemble the openFDA `search=` Lucene string from structured filters — each
173
+ * value Lucene-escaped + phrase-quoted, joined by ` AND `. Returns "" when no
174
+ * filter is present (openFDA then returns the whole category). The mapping of
175
+ * clause → field is FIXED here; a caller can never inject a raw field:value.
176
+ */
177
+ export function buildSearch(f: OpenfdaFilters): string {
178
+ const clauses: string[] = [];
179
+ if (f.firm !== undefined) clauses.push(`recalling_firm:${luceneQuote(f.firm)}`);
180
+ if (f.product !== undefined)
181
+ clauses.push(`product_description:${luceneQuote(f.product)}`);
182
+ if (f.reason !== undefined)
183
+ clauses.push(`reason_for_recall:${luceneQuote(f.reason)}`);
184
+ if (f.classification !== undefined)
185
+ clauses.push(`classification:${luceneQuote(f.classification)}`);
186
+ if (f.status !== undefined) clauses.push(`status:${luceneQuote(f.status)}`);
187
+ if (f.state !== undefined)
188
+ clauses.push(`state:${luceneQuote(f.state.toUpperCase())}`);
189
+ return clauses.join(" AND ");
190
+ }
191
+
192
+ // ─── Bespoke SSRF-guarded fetch (fixed host + assert + redirect:"error") ──
193
+ /**
194
+ * A SINGLE classified `fetch` to api.fda.gov (NOT the shared getJson — we must
195
+ * READ a 404 body to distinguish a NOT_FOUND no-match from a real outage; see the
196
+ * ★P2 crux in the caller). Builds the URL on the FIXED host from `params`, asserts
197
+ * the constructed URL cannot have been steered off-host (belt-and-suspenders), and
198
+ * sets `redirect:"error"` (fail closed on any off-host 3xx — a redirect could
199
+ * carry the api_key away). Returns the raw Response for the caller to classify. A
200
+ * timeout/abort ⇒ non-retryable upstream_unavailable; a network TypeError ⇒
201
+ * retryable upstream_unavailable; a redirect TypeError ⇒ schema_drift (never a
202
+ * fake-empty). `label` is host+path only.
203
+ */
204
+ export async function fetchOpenfda(url: string, label: string): Promise<Response> {
205
+ const built = new URL(url);
206
+ if (built.hostname !== OPENFDA_HOST || built.protocol !== "https:") {
207
+ throw new ToolErrorCarrier({
208
+ kind: "invalid_input",
209
+ retryable: false,
210
+ message: `Constructed openFDA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${OPENFDA_HOST} over https — refusing to fetch (SSRF safety).`,
211
+ upstreamEndpoint: label,
212
+ });
213
+ }
214
+ try {
215
+ return await fetch(built.toString(), {
216
+ redirect: "error",
217
+ signal: AbortSignal.timeout(15_000),
218
+ });
219
+ } catch (e) {
220
+ if (isRedirectError(e)) {
221
+ throw driftError(
222
+ label,
223
+ `openFDA returned an off-host redirect (redirect:"error") while fetching ${label} — refusing to follow it (SSRF safety).`,
224
+ );
225
+ }
226
+ if (
227
+ e instanceof Error &&
228
+ (e.name === "TimeoutError" || e.name === "AbortError")
229
+ ) {
230
+ throw new ToolErrorCarrier({
231
+ kind: "upstream_unavailable",
232
+ message: `Request to ${label} timed out.`,
233
+ retryable: false,
234
+ upstreamEndpoint: label,
235
+ });
236
+ }
237
+ throw new ToolErrorCarrier({
238
+ kind: "upstream_unavailable",
239
+ message: `Network error reaching ${label}: ${e instanceof Error ? e.message : String(e)}`,
240
+ retryable: true,
241
+ retryAfterSeconds: 30,
242
+ upstreamEndpoint: label,
243
+ });
244
+ }
245
+ }
246
+
247
+ /** Best-effort read of an openFDA error body `{error:{code,message}}`. null on any failure. */
248
+ export async function readOpenfdaError(
249
+ res: Response,
250
+ ): Promise<{ code: string | null; message: string | null }> {
251
+ try {
252
+ const body = (await res.json()) as {
253
+ error?: { code?: unknown; message?: unknown };
254
+ };
255
+ return {
256
+ code: str(body?.error?.code),
257
+ message: str(body?.error?.message),
258
+ };
259
+ } catch {
260
+ return { code: null, message: null };
261
+ }
262
+ }
263
+
264
+ // ─── Tool: openfda_enforcement ────────────────────────────────────
265
+ export type OpenfdaEnforcementArgs = OpenfdaFilters & {
266
+ category?: string; // drug | device | food (default drug)
267
+ limit?: number; // 1..100 (default 25)
268
+ skip?: number; // offset ≥ 0 (default 0)
269
+ };
270
+
271
+ /**
272
+ * Search openFDA recall/enforcement records for a category (drug/device/food) with
273
+ * structured filters → curated recall rows + honest `_meta`. KEYLESS (an OPTIONAL
274
+ * OPENFDA_API_KEY only raises the rate limit). totalAvailable = meta.results.total
275
+ * (EXACT); skip/limit offset pagination. ★A no-match query (openFDA HTTP 404
276
+ * NOT_FOUND) ⇒ an honest empty, never a throw.
277
+ */
278
+ export async function enforcement(
279
+ args: OpenfdaEnforcementArgs,
280
+ ): Promise<MetaBundle> {
281
+ // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
282
+ // a DIRECT handler call bypasses Zod). ──
283
+ const category = args.category ?? "drug";
284
+ const label = labelFor(category);
285
+ if (!OPENFDA_CATEGORY_SET.has(category)) {
286
+ throw new ToolErrorCarrier({
287
+ kind: "invalid_input",
288
+ retryable: false,
289
+ message: `Invalid category ${JSON.stringify(category)} — expected one of ${OPENFDA_CATEGORIES.join(", ")} (it rides in the request PATH; strictly validated).`,
290
+ upstreamEndpoint: label,
291
+ });
292
+ }
293
+ if (
294
+ args.classification !== undefined &&
295
+ !OPENFDA_CLASSIFICATION_SET.has(args.classification)
296
+ ) {
297
+ throw new ToolErrorCarrier({
298
+ kind: "invalid_input",
299
+ retryable: false,
300
+ message: `Invalid classification ${JSON.stringify(args.classification)} — expected one of ${OPENFDA_CLASSIFICATIONS.join(", ")}.`,
301
+ upstreamEndpoint: label,
302
+ });
303
+ }
304
+ if (args.state !== undefined && !STATE_RE.test(args.state)) {
305
+ throw new ToolErrorCarrier({
306
+ kind: "invalid_input",
307
+ retryable: false,
308
+ message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-letter US state/territory postal code (^[A-Za-z]{2}$), e.g. "CA".`,
309
+ upstreamEndpoint: label,
310
+ });
311
+ }
312
+ const limit = clampLimit(args.limit);
313
+ const skip = clampSkip(args.skip);
314
+
315
+ // ── Assemble the query (structured-only; all VALUES via URLSearchParams — no
316
+ // host/path steer). The OPTIONAL key rides ONLY here in &api_key=. ──
317
+ const filters: OpenfdaFilters = {
318
+ firm: args.firm,
319
+ product: args.product,
320
+ reason: args.reason,
321
+ classification: args.classification,
322
+ status: args.status,
323
+ state: args.state,
324
+ };
325
+ const search = buildSearch(filters);
326
+ const filtersApplied: string[] = [];
327
+ if (args.firm !== undefined) filtersApplied.push("firm");
328
+ if (args.product !== undefined) filtersApplied.push("product");
329
+ if (args.reason !== undefined) filtersApplied.push("reason");
330
+ if (args.classification !== undefined) filtersApplied.push("classification");
331
+ if (args.status !== undefined) filtersApplied.push("status");
332
+ if (args.state !== undefined) filtersApplied.push("state");
333
+ // NO_FILTER_NOTE gates on the STRUCTURED filters only — capture that BEFORE the
334
+ // always-present category selector is appended below.
335
+ const hasStructuredFilter = filtersApplied.length > 0;
336
+ // The category (drug|device|food) is the ENDPOINT selector — always applied and
337
+ // consequential — so echo it in filtersApplied to make the choice VISIBLE (a
338
+ // device query left on the drug default must not look identical to a device query).
339
+ const categoryDefaulted = args.category === undefined;
340
+ filtersApplied.push(`category:${category}`);
341
+
342
+ const params = new URLSearchParams();
343
+ if (search !== "") params.set("search", search);
344
+ params.set("limit", String(limit));
345
+ params.set("skip", String(skip));
346
+ const key = openfdaApiKey();
347
+ if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
348
+
349
+ const url = `https://${OPENFDA_HOST}/${category}/enforcement.json?${params.toString()}`;
350
+
351
+ // ── Fetch + classify. ★P2 CRUX: a 404 whose body is {error:{code:"NOT_FOUND"}}
352
+ // is a genuine no-match (or an unknown field) ⇒ an HONEST EMPTY, never a
353
+ // thrown not_found. Any OTHER 4xx (e.g. 400 syntax) ⇒ invalid_input surfacing
354
+ // openFDA's message; a 5xx/429 ⇒ the shared taxonomy THROWS. ──
355
+ const res = await fetchOpenfda(url, label);
356
+
357
+ if (res.status === 404) {
358
+ const { code, message } = await readOpenfdaError(res);
359
+ if (code === "NOT_FOUND") {
360
+ // Honest empty — NOT thrown, NOT not_found (the openFDA no-match idiom).
361
+ return emptyResult(category, limit, skip, filtersApplied, key !== undefined, categoryDefaulted);
362
+ }
363
+ // A non-NOT_FOUND 404 ⇒ the shared not_found taxonomy (never a fake-empty).
364
+ throw new ToolErrorCarrier({
365
+ ...errorFromResponse(res, label),
366
+ message: message
367
+ ? `openFDA returned HTTP 404 at ${label}: ${message}`
368
+ : `Resource not found at ${label} (HTTP 404).`,
369
+ });
370
+ }
371
+
372
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
373
+ // A 4xx OTHER than 404/429 (e.g. 400 syntax) ⇒ invalid_input surfacing the
374
+ // openFDA error message (a caller-fixable request, never a fake-empty).
375
+ const { message } = await readOpenfdaError(res);
376
+ throw new ToolErrorCarrier({
377
+ kind: "invalid_input",
378
+ retryable: false,
379
+ message: message
380
+ ? `openFDA rejected the request (HTTP ${res.status}) at ${label}: ${message}`
381
+ : `Bad request (HTTP ${res.status}) at ${label}.`,
382
+ upstreamStatus: res.status,
383
+ upstreamEndpoint: label,
384
+ });
385
+ }
386
+
387
+ if (!res.ok) {
388
+ // 429 → rate_limited; 5xx → upstream_unavailable (a DOWN service is NEVER an
389
+ // empty result). Delegated to the shared errors.ts taxonomy.
390
+ throw new ToolErrorCarrier(errorFromResponse(res, label));
391
+ }
392
+
393
+ // ── 200 ⇒ parse JSON; a non-JSON body ⇒ r.json() SyntaxError ⇒ schema_drift
394
+ // (never read as an empty result). ──
395
+ let body: unknown;
396
+ try {
397
+ body = await res.json();
398
+ } catch (e) {
399
+ if (e instanceof SyntaxError) {
400
+ throw driftError(
401
+ label,
402
+ `openFDA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
403
+ );
404
+ }
405
+ throw e;
406
+ }
407
+
408
+ // ── [P4] meta.results (the total carrier) and results (the rows) MUST be present
409
+ // + well-shaped; anything else is drift, never a fabricated empty. ──
410
+ const b = (body ?? {}) as { meta?: unknown; results?: unknown };
411
+ const meta = (b.meta ?? {}) as { results?: unknown };
412
+ const metaResults = meta.results as { total?: unknown } | undefined;
413
+ if (
414
+ metaResults === undefined ||
415
+ metaResults === null ||
416
+ typeof metaResults !== "object"
417
+ ) {
418
+ throw driftError(
419
+ label,
420
+ `openFDA ${label} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
421
+ );
422
+ }
423
+ if (!Array.isArray(b.results)) {
424
+ throw driftError(
425
+ label,
426
+ `openFDA ${label} shape drift — results must be an array.`,
427
+ );
428
+ }
429
+
430
+ const recalls = (b.results as unknown[]).map(mapRecall);
431
+ const returned = recalls.length;
432
+
433
+ // ── [P1] totalAvailable = meta.results.total EXACT (the REAL total), NEVER
434
+ // results.length. skip/limit offset pagination. ──
435
+ const rawTotal = metaResults.total;
436
+ const totalAvailable =
437
+ typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
438
+ const hasMore =
439
+ totalAvailable !== null && skip + returned < totalAvailable;
440
+ const nextOffset = hasMore ? skip + returned : null;
441
+
442
+ const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
443
+ if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
444
+ if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
445
+
446
+ return withMeta(
447
+ { recalls },
448
+ {
449
+ // MODE only — never the key value (K-test).
450
+ source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
451
+ key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
452
+ })`,
453
+ keylessMode: true, // a keyless tool; the optional key only raises the rate limit
454
+ returned,
455
+ totalAvailable,
456
+ filtersApplied,
457
+ filtersDropped: [],
458
+ fieldsUnavailable: [],
459
+ pagination: { offset: skip, limit, hasMore, nextOffset },
460
+ notes,
461
+ } satisfies Partial<ResponseMeta>,
462
+ );
463
+ }
464
+
465
+ // ─── Small helpers ────────────────────────────────────────────────
466
+ function keyNote(hasKey: boolean): string {
467
+ return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
468
+ }
469
+
470
+ /** An honest empty result (★P2: a 404 NOT_FOUND no-match) — returned:0, total:0. */
471
+ function emptyResult(
472
+ category: string,
473
+ limit: number,
474
+ skip: number,
475
+ filtersApplied: string[],
476
+ hasKey: boolean,
477
+ categoryDefaulted: boolean,
478
+ ): MetaBundle {
479
+ const notes = [
480
+ "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.",
481
+ NOT_DETERMINATION_NOTE,
482
+ keyNote(hasKey),
483
+ ];
484
+ // A defaulted category on an EMPTY result is the most misleading case — a device
485
+ // analyst reads "0 recalls" as "clean" when they actually searched the wrong
486
+ // dataset. Surface the default + the alternatives.
487
+ if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
488
+ return withMeta(
489
+ { recalls: [] as OpenfdaRecall[] },
490
+ {
491
+ source: `${OPENFDA_HOST} /${category}/enforcement (openFDA recall enforcement; ${
492
+ hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
493
+ })`,
494
+ keylessMode: true,
495
+ returned: 0,
496
+ totalAvailable: 0,
497
+ filtersApplied,
498
+ filtersDropped: [],
499
+ fieldsUnavailable: [],
500
+ pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
501
+ notes,
502
+ } satisfies Partial<ResponseMeta>,
503
+ );
504
+ }
505
+
506
+ function clampLimit(v: unknown): number {
507
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
508
+ const n = Math.floor(v);
509
+ if (n < 1) return 1;
510
+ if (n > MAX_LIMIT) return MAX_LIMIT;
511
+ return n;
512
+ }
513
+
514
+ function clampSkip(v: unknown): number {
515
+ if (typeof v !== "number" || !Number.isFinite(v)) return 0;
516
+ const n = Math.floor(v);
517
+ return n < 0 ? 0 : n;
518
+ }