@cliwant/mcp-sam-gov 1.4.0 → 1.6.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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +240 -231
  3. package/README.ko.md +240 -231
  4. package/README.md +725 -706
  5. package/dist/cbp-border.d.ts +51 -0
  6. package/dist/cbp-border.d.ts.map +1 -0
  7. package/dist/cbp-border.js +123 -0
  8. package/dist/cbp-border.js.map +1 -0
  9. package/dist/datagov-catalog.d.ts.map +1 -1
  10. package/dist/datagov-catalog.js +16 -2
  11. package/dist/datagov-catalog.js.map +1 -1
  12. package/dist/ecfr.d.ts +2 -2
  13. package/dist/ecfr.d.ts.map +1 -1
  14. package/dist/ecfr.js +24 -10
  15. package/dist/ecfr.js.map +1 -1
  16. package/dist/edgar.d.ts.map +1 -1
  17. package/dist/edgar.js +26 -6
  18. package/dist/edgar.js.map +1 -1
  19. package/dist/epa-envirofacts.d.ts.map +1 -1
  20. package/dist/epa-envirofacts.js +14 -1
  21. package/dist/epa-envirofacts.js.map +1 -1
  22. package/dist/errors.d.ts +10 -0
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +11 -0
  25. package/dist/errors.js.map +1 -1
  26. package/dist/far.d.ts.map +1 -1
  27. package/dist/far.js +3 -1
  28. package/dist/far.js.map +1 -1
  29. package/dist/federal-register.d.ts +2 -2
  30. package/dist/federal-register.d.ts.map +1 -1
  31. package/dist/federal-register.js +26 -10
  32. package/dist/federal-register.js.map +1 -1
  33. package/dist/feedback.d.ts +64 -0
  34. package/dist/feedback.d.ts.map +1 -0
  35. package/dist/feedback.js +131 -0
  36. package/dist/feedback.js.map +1 -0
  37. package/dist/fema.d.ts +36 -0
  38. package/dist/fema.d.ts.map +1 -1
  39. package/dist/fema.js +124 -0
  40. package/dist/fema.js.map +1 -1
  41. package/dist/gov-domains.d.ts +66 -0
  42. package/dist/gov-domains.d.ts.map +1 -0
  43. package/dist/gov-domains.js +211 -0
  44. package/dist/gov-domains.js.map +1 -0
  45. package/dist/nist-controls.d.ts +48 -0
  46. package/dist/nist-controls.d.ts.map +1 -0
  47. package/dist/nist-controls.js +174 -0
  48. package/dist/nist-controls.js.map +1 -0
  49. package/dist/nws-weather.d.ts +57 -0
  50. package/dist/nws-weather.d.ts.map +1 -0
  51. package/dist/nws-weather.js +131 -0
  52. package/dist/nws-weather.js.map +1 -0
  53. package/dist/openfda-drugsfda.d.ts +72 -0
  54. package/dist/openfda-drugsfda.d.ts.map +1 -0
  55. package/dist/openfda-drugsfda.js +230 -0
  56. package/dist/openfda-drugsfda.js.map +1 -0
  57. package/dist/openfda.d.ts.map +1 -1
  58. package/dist/openfda.js +31 -8
  59. package/dist/openfda.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +374 -11
  62. package/dist/server.js.map +1 -1
  63. package/dist/treasury.d.ts +2 -0
  64. package/dist/treasury.d.ts.map +1 -1
  65. package/dist/treasury.js +7 -0
  66. package/dist/treasury.js.map +1 -1
  67. package/dist/usaspending.d.ts +32 -1
  68. package/dist/usaspending.d.ts.map +1 -1
  69. package/dist/usaspending.js +143 -16
  70. package/dist/usaspending.js.map +1 -1
  71. package/package.json +111 -111
  72. package/src/attachments.ts +652 -652
  73. package/src/bea.ts +372 -372
  74. package/src/bls.ts +1943 -1943
  75. package/src/cache.ts +73 -73
  76. package/src/cbp-border.ts +177 -0
  77. package/src/census-economic.ts +431 -431
  78. package/src/census.ts +735 -735
  79. package/src/ckan.ts +495 -495
  80. package/src/clinicaltrials.ts +923 -923
  81. package/src/cms-facility.ts +379 -379
  82. package/src/cms-hospital.ts +344 -344
  83. package/src/cms-supplier.ts +527 -527
  84. package/src/cms-utilization.ts +389 -389
  85. package/src/cms.ts +634 -634
  86. package/src/coerce.ts +47 -47
  87. package/src/courtlistener.ts +465 -465
  88. package/src/cpsc.ts +333 -333
  89. package/src/datagov-catalog.ts +312 -296
  90. package/src/datagov.ts +907 -907
  91. package/src/datagovKey.ts +68 -68
  92. package/src/datasource.ts +721 -721
  93. package/src/disclosure.ts +61 -61
  94. package/src/dol.ts +515 -515
  95. package/src/ecfr.ts +248 -231
  96. package/src/echo.ts +496 -496
  97. package/src/edgar.ts +3046 -3014
  98. package/src/epa-envirofacts.ts +358 -342
  99. package/src/errors.ts +324 -303
  100. package/src/fac.ts +529 -529
  101. package/src/far.ts +1009 -1007
  102. package/src/fdic.ts +2052 -2052
  103. package/src/federal-register.ts +725 -706
  104. package/src/feedback.ts +160 -0
  105. package/src/fema.ts +680 -541
  106. package/src/fpds.ts +620 -620
  107. package/src/fred.ts +464 -464
  108. package/src/gao.ts +744 -744
  109. package/src/gov-domains.ts +237 -0
  110. package/src/govinfo.ts +497 -497
  111. package/src/grants.ts +290 -290
  112. package/src/gsa-csv.ts +992 -992
  113. package/src/gsa-perdiem.ts +361 -361
  114. package/src/integrity.ts +928 -928
  115. package/src/keys.ts +268 -268
  116. package/src/lda.ts +385 -385
  117. package/src/meta.ts +292 -292
  118. package/src/nhtsa.ts +352 -352
  119. package/src/nih.ts +375 -375
  120. package/src/nist-controls.ts +219 -0
  121. package/src/nonprofit.ts +460 -460
  122. package/src/nppes.ts +834 -834
  123. package/src/nsf.ts +706 -706
  124. package/src/nvd.ts +1124 -1124
  125. package/src/nws-weather.ts +167 -0
  126. package/src/ofac.ts +1166 -1166
  127. package/src/openfda-device.ts +356 -356
  128. package/src/openfda-drugsfda.ts +313 -0
  129. package/src/openfda.ts +518 -495
  130. package/src/pricing.ts +1075 -1075
  131. package/src/sam-gov/client.ts +774 -774
  132. package/src/sam-gov/index.ts +32 -32
  133. package/src/sam-gov/types.ts +152 -152
  134. package/src/sba.ts +357 -357
  135. package/src/server.ts +6688 -6297
  136. package/src/snapshot.ts +223 -223
  137. package/src/socrata.ts +532 -532
  138. package/src/treasury.ts +582 -575
  139. package/src/usaspending.ts +2852 -2680
  140. package/src/usitc.ts +420 -420
package/src/openfda.ts CHANGED
@@ -1,495 +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
-
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
- }
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
+ }