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