@cliwant/mcp-sam-gov 1.5.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 (84) 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 -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 +44 -2
  14. package/dist/server.js.map +1 -1
  15. package/package.json +111 -111
  16. package/src/attachments.ts +652 -652
  17. package/src/bea.ts +372 -372
  18. package/src/bls.ts +1943 -1943
  19. package/src/cache.ts +73 -73
  20. package/src/cbp-border.ts +177 -177
  21. package/src/census-economic.ts +431 -431
  22. package/src/census.ts +735 -735
  23. package/src/ckan.ts +495 -495
  24. package/src/clinicaltrials.ts +923 -923
  25. package/src/cms-facility.ts +379 -379
  26. package/src/cms-hospital.ts +344 -344
  27. package/src/cms-supplier.ts +527 -527
  28. package/src/cms-utilization.ts +389 -389
  29. package/src/cms.ts +634 -634
  30. package/src/coerce.ts +47 -47
  31. package/src/courtlistener.ts +465 -465
  32. package/src/cpsc.ts +333 -333
  33. package/src/datagov-catalog.ts +312 -312
  34. package/src/datagov.ts +907 -907
  35. package/src/datagovKey.ts +68 -68
  36. package/src/datasource.ts +721 -721
  37. package/src/disclosure.ts +61 -61
  38. package/src/dol.ts +515 -515
  39. package/src/ecfr.ts +248 -248
  40. package/src/echo.ts +496 -496
  41. package/src/edgar.ts +3046 -3046
  42. package/src/epa-envirofacts.ts +358 -358
  43. package/src/errors.ts +324 -314
  44. package/src/fac.ts +529 -529
  45. package/src/far.ts +1009 -1009
  46. package/src/fdic.ts +2052 -2052
  47. package/src/federal-register.ts +725 -725
  48. package/src/feedback.ts +160 -0
  49. package/src/fema.ts +680 -680
  50. package/src/fpds.ts +620 -620
  51. package/src/fred.ts +464 -464
  52. package/src/gao.ts +744 -744
  53. package/src/gov-domains.ts +237 -237
  54. package/src/govinfo.ts +497 -497
  55. package/src/grants.ts +290 -290
  56. package/src/gsa-csv.ts +992 -992
  57. package/src/gsa-perdiem.ts +361 -361
  58. package/src/integrity.ts +928 -928
  59. package/src/keys.ts +268 -268
  60. package/src/lda.ts +385 -385
  61. package/src/meta.ts +292 -292
  62. package/src/nhtsa.ts +352 -352
  63. package/src/nih.ts +375 -375
  64. package/src/nist-controls.ts +219 -219
  65. package/src/nonprofit.ts +460 -460
  66. package/src/nppes.ts +834 -834
  67. package/src/nsf.ts +706 -706
  68. package/src/nvd.ts +1124 -1124
  69. package/src/nws-weather.ts +167 -167
  70. package/src/ofac.ts +1166 -1166
  71. package/src/openfda-device.ts +356 -356
  72. package/src/openfda-drugsfda.ts +313 -313
  73. package/src/openfda.ts +518 -518
  74. package/src/pricing.ts +1075 -1075
  75. package/src/sam-gov/client.ts +774 -774
  76. package/src/sam-gov/index.ts +32 -32
  77. package/src/sam-gov/types.ts +152 -152
  78. package/src/sba.ts +357 -357
  79. package/src/server.ts +6688 -6639
  80. package/src/snapshot.ts +223 -223
  81. package/src/socrata.ts +532 -532
  82. package/src/treasury.ts +582 -582
  83. package/src/usaspending.ts +2852 -2852
  84. package/src/usitc.ts +420 -420
@@ -1,313 +1,313 @@
1
- /**
2
- * openfda-drugsfda.ts — openFDA DRUG APPROVALS (Drugs@FDA, api.fda.gov) — the
3
- * PHARMA REGULATORY-APPROVAL lane. FDA-approved drug applications (NDA/ANDA/BLA):
4
- * the sponsor, the application number, each approved product (brand/generic name,
5
- * dosage form, route, marketing status), and the submission/approval history.
6
- * Answers "what drugs did sponsor X get approved, and which are still marketed" —
7
- * pharma vendor product/approval intelligence.
8
- *
9
- * ★ SAME SOURCE + ENVELOPE + CRUX as openfda.ts / openfda-device.ts (ADR-0054/0056).
10
- * Same host (api.fda.gov), same envelope `{ meta:{ results:{ skip,limit,total }},
11
- * results:[…] }`, same ★no-match→HTTP-404-NOT_FOUND-as-honest-empty crux, same
12
- * optional query-key K-test, same fixed-host SSRF idiom, same structured-only (no
13
- * raw Lucene passthrough) search. REUSES openfda.ts's `fetchOpenfda`,
14
- * `readOpenfdaError`, `luceneQuote`, `openfdaApiKey`, `OPENFDA_HOST` verbatim.
15
- *
16
- * GET https://api.fda.gov/drug/drugsfda.json?search=<lucene>&limit=&skip=[&api_key=]
17
- * → { meta:{ results:{ skip,limit,total }}, results:[ { application_number,
18
- * sponsor_name, products:[…], submissions:[…] } ] }.
19
- *
20
- * ★ HONESTY (mirrors the siblings exactly): [P1] totalAvailable = meta.results.total
21
- * EXACT (never results.length); skip/limit offset pagination. [★P2] 404 NOT_FOUND
22
- * ⇒ honest empty (never thrown); other 4xx ⇒ invalid_input surfacing openFDA's
23
- * message; 5xx/429 ⇒ THROW. [P3] every scalar via `str` (null-never-""); nested
24
- * products/submissions arrays default to [] and each field is str (never fabricated).
25
- * [P4] meta.results / results absent-or-mis-shaped ⇒ driftError. [K-test] the
26
- * OPTIONAL OPENFDA_API_KEY rides ONLY &api_key=; label/source name mode only.
27
- * [SSRF] fixed host; all VALUES Lucene-escaped + phrase-quoted via URLSearchParams.
28
- */
29
-
30
- import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
31
- import { driftError } from "./datasource.js";
32
- import { str } from "./coerce.js";
33
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
34
- import {
35
- OPENFDA_HOST,
36
- fetchOpenfda,
37
- readOpenfdaError,
38
- luceneQuote,
39
- openfdaApiKey,
40
- } from "./openfda.js";
41
-
42
- const DEFAULT_LIMIT = 25;
43
- const MAX_LIMIT = 100;
44
-
45
- /** host+path-only label (→ ToolError.upstreamEndpoint); NEVER carries the key. */
46
- const DRUGSFDA_LABEL = "openfda:/drug/drugsfda";
47
-
48
- // ─── Honesty notes ────────────────────────────────────────────────
49
- const NOT_DETERMINATION_NOTE =
50
- "openFDA Drugs@FDA records are FDA-published drug-application data; treat approval/marketing status as of the source's last publication. This is reference data, not a live regulatory determination.";
51
- const KEYLESS_NOTE =
52
- "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/).";
53
- const KEYED_NOTE =
54
- "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.";
55
- const NO_FILTER_NOTE =
56
- "No structured filters were applied — this is an unscoped scan of the WHOLE Drugs@FDA collection. Add sponsorName / brandName / activeIngredient / applicationNumber to scope the result set.";
57
- const MARKETING_NOTE =
58
- "products[].marketingStatus is the FDA marketing category (e.g. 'Prescription', 'Over-the-counter', 'Discontinued', 'None (Tentative Approval)') — a 'Discontinued' product is NOT an approval revocation. submissions[] is the application's action history (ORIG approval + subsequent supplements).";
59
-
60
- // ─── Curated row shape ────────────────────────────────────────────
61
- export type DrugsfdaProduct = {
62
- brandName: string | null;
63
- genericIngredients: { name: string | null; strength: string | null }[];
64
- dosageForm: string | null;
65
- route: string | null;
66
- marketingStatus: string | null;
67
- };
68
- export type DrugsfdaSubmission = {
69
- submissionType: string | null; // ORIG / SUPPL
70
- submissionNumber: string | null;
71
- submissionStatus: string | null; // AP (approved) etc.
72
- submissionStatusDate: string | null; // YYYYMMDD string, preserved (P3)
73
- submissionClass: string | null;
74
- };
75
- export type DrugsfdaApplication = {
76
- applicationNumber: string | null;
77
- sponsorName: string | null;
78
- products: DrugsfdaProduct[];
79
- submissions: DrugsfdaSubmission[];
80
- };
81
-
82
- /** Map ONE Drugs@FDA result row → the curated shape. Every scalar via `str`. */
83
- function mapApplication(row: unknown): DrugsfdaApplication {
84
- const r = (row ?? {}) as Record<string, unknown>;
85
- const products = Array.isArray(r.products) ? r.products : [];
86
- const submissions = Array.isArray(r.submissions) ? r.submissions : [];
87
- return {
88
- applicationNumber: str(r.application_number),
89
- sponsorName: str(r.sponsor_name),
90
- products: products.map((p) => {
91
- const pr = (p ?? {}) as Record<string, unknown>;
92
- const ings = Array.isArray(pr.active_ingredients) ? pr.active_ingredients : [];
93
- return {
94
- brandName: str(pr.brand_name),
95
- genericIngredients: ings.map((a) => {
96
- const ai = (a ?? {}) as Record<string, unknown>;
97
- return { name: str(ai.name), strength: str(ai.strength) };
98
- }),
99
- dosageForm: str(pr.dosage_form),
100
- route: str(pr.route),
101
- marketingStatus: str(pr.marketing_status),
102
- };
103
- }),
104
- submissions: submissions.map((s) => {
105
- const su = (s ?? {}) as Record<string, unknown>;
106
- return {
107
- submissionType: str(su.submission_type),
108
- submissionNumber: str(su.submission_number),
109
- submissionStatus: str(su.submission_status),
110
- submissionStatusDate: str(su.submission_status_date),
111
- submissionClass: str(su.submission_class_code_description),
112
- };
113
- }),
114
- };
115
- }
116
-
117
- // ─── Lucene search assembly (structured-only; injection-safe) ─────
118
- export type DrugsfdaFilters = {
119
- sponsorName?: string; // → sponsor_name
120
- brandName?: string; // → products.brand_name
121
- activeIngredient?: string; // → products.active_ingredients.name
122
- applicationNumber?: string; // → application_number
123
- };
124
-
125
- export function buildDrugsfdaSearch(f: DrugsfdaFilters): string {
126
- const clauses: string[] = [];
127
- if (f.sponsorName !== undefined)
128
- clauses.push(`sponsor_name:${luceneQuote(f.sponsorName)}`);
129
- if (f.brandName !== undefined)
130
- clauses.push(`products.brand_name:${luceneQuote(f.brandName)}`);
131
- if (f.activeIngredient !== undefined)
132
- clauses.push(`products.active_ingredients.name:${luceneQuote(f.activeIngredient)}`);
133
- if (f.applicationNumber !== undefined)
134
- clauses.push(`application_number:${luceneQuote(f.applicationNumber)}`);
135
- return clauses.join(" AND ");
136
- }
137
-
138
- // ─── Tool: openfda_drug_approvals ─────────────────────────────────
139
- export type DrugApprovalsArgs = DrugsfdaFilters & {
140
- limit?: number;
141
- skip?: number;
142
- };
143
-
144
- /**
145
- * Search openFDA Drugs@FDA drug-approval applications with structured filters →
146
- * curated application rows (sponsor, application number, approved products, submission
147
- * history) + honest `_meta`. KEYLESS (OPTIONAL OPENFDA_API_KEY only raises the rate
148
- * limit). totalAvailable = meta.results.total (EXACT); skip/limit pagination. A
149
- * no-match query (openFDA HTTP 404 NOT_FOUND) ⇒ an honest empty, never a throw.
150
- */
151
- export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle> {
152
- const limit = clampLimit(args.limit);
153
- const skip = clampSkip(args.skip);
154
-
155
- const filters: DrugsfdaFilters = {
156
- sponsorName: args.sponsorName,
157
- brandName: args.brandName,
158
- activeIngredient: args.activeIngredient,
159
- applicationNumber: args.applicationNumber,
160
- };
161
- const search = buildDrugsfdaSearch(filters);
162
- const filtersApplied: string[] = [];
163
- if (args.sponsorName !== undefined) filtersApplied.push("sponsorName");
164
- if (args.brandName !== undefined) filtersApplied.push("brandName");
165
- if (args.activeIngredient !== undefined) filtersApplied.push("activeIngredient");
166
- if (args.applicationNumber !== undefined) filtersApplied.push("applicationNumber");
167
-
168
- const params = new URLSearchParams();
169
- if (search !== "") params.set("search", search);
170
- params.set("limit", String(limit));
171
- params.set("skip", String(skip));
172
- const key = openfdaApiKey();
173
- if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
174
-
175
- const url = `https://${OPENFDA_HOST}/drug/drugsfda.json?${params.toString()}`;
176
-
177
- const res = await fetchOpenfda(url, DRUGSFDA_LABEL);
178
-
179
- if (res.status === 404) {
180
- const { code, message } = await readOpenfdaError(res);
181
- if (code === "NOT_FOUND") {
182
- return emptyResult(limit, skip, filtersApplied, key !== undefined);
183
- }
184
- throw new ToolErrorCarrier({
185
- ...errorFromResponse(res, DRUGSFDA_LABEL),
186
- message: message
187
- ? `openFDA returned HTTP 404 at ${DRUGSFDA_LABEL}: ${message}`
188
- : `Resource not found at ${DRUGSFDA_LABEL} (HTTP 404).`,
189
- });
190
- }
191
-
192
- if (res.status >= 400 && res.status < 500 && res.status !== 429) {
193
- const { message } = await readOpenfdaError(res);
194
- throw new ToolErrorCarrier({
195
- kind: "invalid_input",
196
- retryable: false,
197
- message: message
198
- ? `openFDA rejected the request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}: ${message}`
199
- : `Bad request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}.`,
200
- upstreamStatus: res.status,
201
- upstreamEndpoint: DRUGSFDA_LABEL,
202
- });
203
- }
204
-
205
- if (!res.ok) {
206
- throw new ToolErrorCarrier(errorFromResponse(res, DRUGSFDA_LABEL));
207
- }
208
-
209
- let body: unknown;
210
- try {
211
- body = await res.json();
212
- } catch (e) {
213
- if (e instanceof SyntaxError) {
214
- throw driftError(
215
- DRUGSFDA_LABEL,
216
- `openFDA ${DRUGSFDA_LABEL} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
217
- );
218
- }
219
- throw e;
220
- }
221
-
222
- const b = (body ?? {}) as { meta?: unknown; results?: unknown };
223
- const meta = (b.meta ?? {}) as { results?: unknown };
224
- const metaResults = meta.results as { total?: unknown } | undefined;
225
- if (metaResults === undefined || metaResults === null || typeof metaResults !== "object") {
226
- throw driftError(
227
- DRUGSFDA_LABEL,
228
- `openFDA ${DRUGSFDA_LABEL} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
229
- );
230
- }
231
- if (!Array.isArray(b.results)) {
232
- throw driftError(
233
- DRUGSFDA_LABEL,
234
- `openFDA ${DRUGSFDA_LABEL} shape drift — results must be an array.`,
235
- );
236
- }
237
-
238
- const applications = (b.results as unknown[]).map(mapApplication);
239
- const returned = applications.length;
240
-
241
- const rawTotal = metaResults.total;
242
- const totalAvailable =
243
- typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
244
- const hasMore = totalAvailable !== null && skip + returned < totalAvailable;
245
- const nextOffset = hasMore ? skip + returned : null;
246
-
247
- const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
248
- if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
249
-
250
- return withMeta(
251
- { applications },
252
- {
253
- source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
254
- key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
255
- })`,
256
- keylessMode: true,
257
- returned,
258
- totalAvailable,
259
- filtersApplied,
260
- filtersDropped: [],
261
- fieldsUnavailable: [],
262
- pagination: { offset: skip, limit, hasMore, nextOffset },
263
- notes,
264
- } satisfies Partial<ResponseMeta>,
265
- );
266
- }
267
-
268
- // ─── Small helpers ────────────────────────────────────────────────
269
- function keyNote(hasKey: boolean): string {
270
- return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
271
- }
272
-
273
- function emptyResult(
274
- limit: number,
275
- skip: number,
276
- filtersApplied: string[],
277
- hasKey: boolean,
278
- ): MetaBundle {
279
- return withMeta(
280
- { applications: [] as DrugsfdaApplication[] },
281
- {
282
- source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
283
- hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
284
- })`,
285
- keylessMode: true,
286
- returned: 0,
287
- totalAvailable: 0,
288
- filtersApplied,
289
- filtersDropped: [],
290
- fieldsUnavailable: [],
291
- pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
292
- notes: [
293
- "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
294
- NOT_DETERMINATION_NOTE,
295
- keyNote(hasKey),
296
- ],
297
- } satisfies Partial<ResponseMeta>,
298
- );
299
- }
300
-
301
- function clampLimit(v: unknown): number {
302
- if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
303
- const n = Math.floor(v);
304
- if (n < 1) return 1;
305
- if (n > MAX_LIMIT) return MAX_LIMIT;
306
- return n;
307
- }
308
-
309
- function clampSkip(v: unknown): number {
310
- if (typeof v !== "number" || !Number.isFinite(v)) return 0;
311
- const n = Math.floor(v);
312
- return n < 0 ? 0 : n;
313
- }
1
+ /**
2
+ * openfda-drugsfda.ts — openFDA DRUG APPROVALS (Drugs@FDA, api.fda.gov) — the
3
+ * PHARMA REGULATORY-APPROVAL lane. FDA-approved drug applications (NDA/ANDA/BLA):
4
+ * the sponsor, the application number, each approved product (brand/generic name,
5
+ * dosage form, route, marketing status), and the submission/approval history.
6
+ * Answers "what drugs did sponsor X get approved, and which are still marketed" —
7
+ * pharma vendor product/approval intelligence.
8
+ *
9
+ * ★ SAME SOURCE + ENVELOPE + CRUX as openfda.ts / openfda-device.ts (ADR-0054/0056).
10
+ * Same host (api.fda.gov), same envelope `{ meta:{ results:{ skip,limit,total }},
11
+ * results:[…] }`, same ★no-match→HTTP-404-NOT_FOUND-as-honest-empty crux, same
12
+ * optional query-key K-test, same fixed-host SSRF idiom, same structured-only (no
13
+ * raw Lucene passthrough) search. REUSES openfda.ts's `fetchOpenfda`,
14
+ * `readOpenfdaError`, `luceneQuote`, `openfdaApiKey`, `OPENFDA_HOST` verbatim.
15
+ *
16
+ * GET https://api.fda.gov/drug/drugsfda.json?search=<lucene>&limit=&skip=[&api_key=]
17
+ * → { meta:{ results:{ skip,limit,total }}, results:[ { application_number,
18
+ * sponsor_name, products:[…], submissions:[…] } ] }.
19
+ *
20
+ * ★ HONESTY (mirrors the siblings exactly): [P1] totalAvailable = meta.results.total
21
+ * EXACT (never results.length); skip/limit offset pagination. [★P2] 404 NOT_FOUND
22
+ * ⇒ honest empty (never thrown); other 4xx ⇒ invalid_input surfacing openFDA's
23
+ * message; 5xx/429 ⇒ THROW. [P3] every scalar via `str` (null-never-""); nested
24
+ * products/submissions arrays default to [] and each field is str (never fabricated).
25
+ * [P4] meta.results / results absent-or-mis-shaped ⇒ driftError. [K-test] the
26
+ * OPTIONAL OPENFDA_API_KEY rides ONLY &api_key=; label/source name mode only.
27
+ * [SSRF] fixed host; all VALUES Lucene-escaped + phrase-quoted via URLSearchParams.
28
+ */
29
+
30
+ import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
31
+ import { driftError } from "./datasource.js";
32
+ import { str } from "./coerce.js";
33
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
34
+ import {
35
+ OPENFDA_HOST,
36
+ fetchOpenfda,
37
+ readOpenfdaError,
38
+ luceneQuote,
39
+ openfdaApiKey,
40
+ } from "./openfda.js";
41
+
42
+ const DEFAULT_LIMIT = 25;
43
+ const MAX_LIMIT = 100;
44
+
45
+ /** host+path-only label (→ ToolError.upstreamEndpoint); NEVER carries the key. */
46
+ const DRUGSFDA_LABEL = "openfda:/drug/drugsfda";
47
+
48
+ // ─── Honesty notes ────────────────────────────────────────────────
49
+ const NOT_DETERMINATION_NOTE =
50
+ "openFDA Drugs@FDA records are FDA-published drug-application data; treat approval/marketing status as of the source's last publication. This is reference data, not a live regulatory determination.";
51
+ const KEYLESS_NOTE =
52
+ "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/).";
53
+ const KEYED_NOTE =
54
+ "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.";
55
+ const NO_FILTER_NOTE =
56
+ "No structured filters were applied — this is an unscoped scan of the WHOLE Drugs@FDA collection. Add sponsorName / brandName / activeIngredient / applicationNumber to scope the result set.";
57
+ const MARKETING_NOTE =
58
+ "products[].marketingStatus is the FDA marketing category (e.g. 'Prescription', 'Over-the-counter', 'Discontinued', 'None (Tentative Approval)') — a 'Discontinued' product is NOT an approval revocation. submissions[] is the application's action history (ORIG approval + subsequent supplements).";
59
+
60
+ // ─── Curated row shape ────────────────────────────────────────────
61
+ export type DrugsfdaProduct = {
62
+ brandName: string | null;
63
+ genericIngredients: { name: string | null; strength: string | null }[];
64
+ dosageForm: string | null;
65
+ route: string | null;
66
+ marketingStatus: string | null;
67
+ };
68
+ export type DrugsfdaSubmission = {
69
+ submissionType: string | null; // ORIG / SUPPL
70
+ submissionNumber: string | null;
71
+ submissionStatus: string | null; // AP (approved) etc.
72
+ submissionStatusDate: string | null; // YYYYMMDD string, preserved (P3)
73
+ submissionClass: string | null;
74
+ };
75
+ export type DrugsfdaApplication = {
76
+ applicationNumber: string | null;
77
+ sponsorName: string | null;
78
+ products: DrugsfdaProduct[];
79
+ submissions: DrugsfdaSubmission[];
80
+ };
81
+
82
+ /** Map ONE Drugs@FDA result row → the curated shape. Every scalar via `str`. */
83
+ function mapApplication(row: unknown): DrugsfdaApplication {
84
+ const r = (row ?? {}) as Record<string, unknown>;
85
+ const products = Array.isArray(r.products) ? r.products : [];
86
+ const submissions = Array.isArray(r.submissions) ? r.submissions : [];
87
+ return {
88
+ applicationNumber: str(r.application_number),
89
+ sponsorName: str(r.sponsor_name),
90
+ products: products.map((p) => {
91
+ const pr = (p ?? {}) as Record<string, unknown>;
92
+ const ings = Array.isArray(pr.active_ingredients) ? pr.active_ingredients : [];
93
+ return {
94
+ brandName: str(pr.brand_name),
95
+ genericIngredients: ings.map((a) => {
96
+ const ai = (a ?? {}) as Record<string, unknown>;
97
+ return { name: str(ai.name), strength: str(ai.strength) };
98
+ }),
99
+ dosageForm: str(pr.dosage_form),
100
+ route: str(pr.route),
101
+ marketingStatus: str(pr.marketing_status),
102
+ };
103
+ }),
104
+ submissions: submissions.map((s) => {
105
+ const su = (s ?? {}) as Record<string, unknown>;
106
+ return {
107
+ submissionType: str(su.submission_type),
108
+ submissionNumber: str(su.submission_number),
109
+ submissionStatus: str(su.submission_status),
110
+ submissionStatusDate: str(su.submission_status_date),
111
+ submissionClass: str(su.submission_class_code_description),
112
+ };
113
+ }),
114
+ };
115
+ }
116
+
117
+ // ─── Lucene search assembly (structured-only; injection-safe) ─────
118
+ export type DrugsfdaFilters = {
119
+ sponsorName?: string; // → sponsor_name
120
+ brandName?: string; // → products.brand_name
121
+ activeIngredient?: string; // → products.active_ingredients.name
122
+ applicationNumber?: string; // → application_number
123
+ };
124
+
125
+ export function buildDrugsfdaSearch(f: DrugsfdaFilters): string {
126
+ const clauses: string[] = [];
127
+ if (f.sponsorName !== undefined)
128
+ clauses.push(`sponsor_name:${luceneQuote(f.sponsorName)}`);
129
+ if (f.brandName !== undefined)
130
+ clauses.push(`products.brand_name:${luceneQuote(f.brandName)}`);
131
+ if (f.activeIngredient !== undefined)
132
+ clauses.push(`products.active_ingredients.name:${luceneQuote(f.activeIngredient)}`);
133
+ if (f.applicationNumber !== undefined)
134
+ clauses.push(`application_number:${luceneQuote(f.applicationNumber)}`);
135
+ return clauses.join(" AND ");
136
+ }
137
+
138
+ // ─── Tool: openfda_drug_approvals ─────────────────────────────────
139
+ export type DrugApprovalsArgs = DrugsfdaFilters & {
140
+ limit?: number;
141
+ skip?: number;
142
+ };
143
+
144
+ /**
145
+ * Search openFDA Drugs@FDA drug-approval applications with structured filters →
146
+ * curated application rows (sponsor, application number, approved products, submission
147
+ * history) + honest `_meta`. KEYLESS (OPTIONAL OPENFDA_API_KEY only raises the rate
148
+ * limit). totalAvailable = meta.results.total (EXACT); skip/limit pagination. A
149
+ * no-match query (openFDA HTTP 404 NOT_FOUND) ⇒ an honest empty, never a throw.
150
+ */
151
+ export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle> {
152
+ const limit = clampLimit(args.limit);
153
+ const skip = clampSkip(args.skip);
154
+
155
+ const filters: DrugsfdaFilters = {
156
+ sponsorName: args.sponsorName,
157
+ brandName: args.brandName,
158
+ activeIngredient: args.activeIngredient,
159
+ applicationNumber: args.applicationNumber,
160
+ };
161
+ const search = buildDrugsfdaSearch(filters);
162
+ const filtersApplied: string[] = [];
163
+ if (args.sponsorName !== undefined) filtersApplied.push("sponsorName");
164
+ if (args.brandName !== undefined) filtersApplied.push("brandName");
165
+ if (args.activeIngredient !== undefined) filtersApplied.push("activeIngredient");
166
+ if (args.applicationNumber !== undefined) filtersApplied.push("applicationNumber");
167
+
168
+ const params = new URLSearchParams();
169
+ if (search !== "") params.set("search", search);
170
+ params.set("limit", String(limit));
171
+ params.set("skip", String(skip));
172
+ const key = openfdaApiKey();
173
+ if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
174
+
175
+ const url = `https://${OPENFDA_HOST}/drug/drugsfda.json?${params.toString()}`;
176
+
177
+ const res = await fetchOpenfda(url, DRUGSFDA_LABEL);
178
+
179
+ if (res.status === 404) {
180
+ const { code, message } = await readOpenfdaError(res);
181
+ if (code === "NOT_FOUND") {
182
+ return emptyResult(limit, skip, filtersApplied, key !== undefined);
183
+ }
184
+ throw new ToolErrorCarrier({
185
+ ...errorFromResponse(res, DRUGSFDA_LABEL),
186
+ message: message
187
+ ? `openFDA returned HTTP 404 at ${DRUGSFDA_LABEL}: ${message}`
188
+ : `Resource not found at ${DRUGSFDA_LABEL} (HTTP 404).`,
189
+ });
190
+ }
191
+
192
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
193
+ const { message } = await readOpenfdaError(res);
194
+ throw new ToolErrorCarrier({
195
+ kind: "invalid_input",
196
+ retryable: false,
197
+ message: message
198
+ ? `openFDA rejected the request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}: ${message}`
199
+ : `Bad request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}.`,
200
+ upstreamStatus: res.status,
201
+ upstreamEndpoint: DRUGSFDA_LABEL,
202
+ });
203
+ }
204
+
205
+ if (!res.ok) {
206
+ throw new ToolErrorCarrier(errorFromResponse(res, DRUGSFDA_LABEL));
207
+ }
208
+
209
+ let body: unknown;
210
+ try {
211
+ body = await res.json();
212
+ } catch (e) {
213
+ if (e instanceof SyntaxError) {
214
+ throw driftError(
215
+ DRUGSFDA_LABEL,
216
+ `openFDA ${DRUGSFDA_LABEL} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
217
+ );
218
+ }
219
+ throw e;
220
+ }
221
+
222
+ const b = (body ?? {}) as { meta?: unknown; results?: unknown };
223
+ const meta = (b.meta ?? {}) as { results?: unknown };
224
+ const metaResults = meta.results as { total?: unknown } | undefined;
225
+ if (metaResults === undefined || metaResults === null || typeof metaResults !== "object") {
226
+ throw driftError(
227
+ DRUGSFDA_LABEL,
228
+ `openFDA ${DRUGSFDA_LABEL} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
229
+ );
230
+ }
231
+ if (!Array.isArray(b.results)) {
232
+ throw driftError(
233
+ DRUGSFDA_LABEL,
234
+ `openFDA ${DRUGSFDA_LABEL} shape drift — results must be an array.`,
235
+ );
236
+ }
237
+
238
+ const applications = (b.results as unknown[]).map(mapApplication);
239
+ const returned = applications.length;
240
+
241
+ const rawTotal = metaResults.total;
242
+ const totalAvailable =
243
+ typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
244
+ const hasMore = totalAvailable !== null && skip + returned < totalAvailable;
245
+ const nextOffset = hasMore ? skip + returned : null;
246
+
247
+ const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
248
+ if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
249
+
250
+ return withMeta(
251
+ { applications },
252
+ {
253
+ source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
254
+ key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
255
+ })`,
256
+ keylessMode: true,
257
+ returned,
258
+ totalAvailable,
259
+ filtersApplied,
260
+ filtersDropped: [],
261
+ fieldsUnavailable: [],
262
+ pagination: { offset: skip, limit, hasMore, nextOffset },
263
+ notes,
264
+ } satisfies Partial<ResponseMeta>,
265
+ );
266
+ }
267
+
268
+ // ─── Small helpers ────────────────────────────────────────────────
269
+ function keyNote(hasKey: boolean): string {
270
+ return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
271
+ }
272
+
273
+ function emptyResult(
274
+ limit: number,
275
+ skip: number,
276
+ filtersApplied: string[],
277
+ hasKey: boolean,
278
+ ): MetaBundle {
279
+ return withMeta(
280
+ { applications: [] as DrugsfdaApplication[] },
281
+ {
282
+ source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
283
+ hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
284
+ })`,
285
+ keylessMode: true,
286
+ returned: 0,
287
+ totalAvailable: 0,
288
+ filtersApplied,
289
+ filtersDropped: [],
290
+ fieldsUnavailable: [],
291
+ pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
292
+ notes: [
293
+ "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
294
+ NOT_DETERMINATION_NOTE,
295
+ keyNote(hasKey),
296
+ ],
297
+ } satisfies Partial<ResponseMeta>,
298
+ );
299
+ }
300
+
301
+ function clampLimit(v: unknown): number {
302
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
303
+ const n = Math.floor(v);
304
+ if (n < 1) return 1;
305
+ if (n > MAX_LIMIT) return MAX_LIMIT;
306
+ return n;
307
+ }
308
+
309
+ function clampSkip(v: unknown): number {
310
+ if (typeof v !== "number" || !Number.isFinite(v)) return 0;
311
+ const n = Math.floor(v);
312
+ return n < 0 ? 0 : n;
313
+ }