@cliwant/mcp-sam-gov 1.3.0 → 1.5.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 (149) hide show
  1. package/README.ja.md +20 -12
  2. package/README.ko.md +20 -12
  3. package/README.md +62 -14
  4. package/dist/bea.d.ts +1 -1
  5. package/dist/bea.js +1 -1
  6. package/dist/cbp-border.d.ts +51 -0
  7. package/dist/cbp-border.d.ts.map +1 -0
  8. package/dist/cbp-border.js +123 -0
  9. package/dist/cbp-border.js.map +1 -0
  10. package/dist/census-economic.d.ts +1 -1
  11. package/dist/census-economic.d.ts.map +1 -1
  12. package/dist/census-economic.js +12 -6
  13. package/dist/census-economic.js.map +1 -1
  14. package/dist/cms-facility.d.ts +112 -0
  15. package/dist/cms-facility.d.ts.map +1 -0
  16. package/dist/cms-facility.js +311 -0
  17. package/dist/cms-facility.js.map +1 -0
  18. package/dist/cms-hospital.d.ts +105 -0
  19. package/dist/cms-hospital.d.ts.map +1 -0
  20. package/dist/cms-hospital.js +290 -0
  21. package/dist/cms-hospital.js.map +1 -0
  22. package/dist/cms-supplier.d.ts +133 -0
  23. package/dist/cms-supplier.d.ts.map +1 -0
  24. package/dist/cms-supplier.js +414 -0
  25. package/dist/cms-supplier.js.map +1 -0
  26. package/dist/cms-utilization.d.ts +113 -0
  27. package/dist/cms-utilization.d.ts.map +1 -0
  28. package/dist/cms-utilization.js +328 -0
  29. package/dist/cms-utilization.js.map +1 -0
  30. package/dist/courtlistener.d.ts +115 -0
  31. package/dist/courtlistener.d.ts.map +1 -0
  32. package/dist/courtlistener.js +398 -0
  33. package/dist/courtlistener.js.map +1 -0
  34. package/dist/cpsc.d.ts +81 -0
  35. package/dist/cpsc.d.ts.map +1 -0
  36. package/dist/cpsc.js +283 -0
  37. package/dist/cpsc.js.map +1 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -1
  39. package/dist/datagov-catalog.js +16 -2
  40. package/dist/datagov-catalog.js.map +1 -1
  41. package/dist/dol.d.ts +2 -2
  42. package/dist/dol.js +5 -5
  43. package/dist/dol.js.map +1 -1
  44. package/dist/ecfr.d.ts +2 -2
  45. package/dist/ecfr.d.ts.map +1 -1
  46. package/dist/ecfr.js +24 -10
  47. package/dist/ecfr.js.map +1 -1
  48. package/dist/edgar.d.ts.map +1 -1
  49. package/dist/edgar.js +26 -6
  50. package/dist/edgar.js.map +1 -1
  51. package/dist/epa-envirofacts.d.ts +97 -0
  52. package/dist/epa-envirofacts.d.ts.map +1 -0
  53. package/dist/epa-envirofacts.js +305 -0
  54. package/dist/epa-envirofacts.js.map +1 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +11 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/far.d.ts.map +1 -1
  59. package/dist/far.js +3 -1
  60. package/dist/far.js.map +1 -1
  61. package/dist/federal-register.d.ts +2 -2
  62. package/dist/federal-register.d.ts.map +1 -1
  63. package/dist/federal-register.js +26 -10
  64. package/dist/federal-register.js.map +1 -1
  65. package/dist/fema.d.ts +36 -0
  66. package/dist/fema.d.ts.map +1 -1
  67. package/dist/fema.js +124 -0
  68. package/dist/fema.js.map +1 -1
  69. package/dist/fred.d.ts +1 -1
  70. package/dist/fred.js +1 -1
  71. package/dist/gov-domains.d.ts +66 -0
  72. package/dist/gov-domains.d.ts.map +1 -0
  73. package/dist/gov-domains.js +211 -0
  74. package/dist/gov-domains.js.map +1 -0
  75. package/dist/keys.d.ts +6 -5
  76. package/dist/keys.d.ts.map +1 -1
  77. package/dist/keys.js +25 -6
  78. package/dist/keys.js.map +1 -1
  79. package/dist/nhtsa.d.ts +91 -0
  80. package/dist/nhtsa.d.ts.map +1 -0
  81. package/dist/nhtsa.js +263 -0
  82. package/dist/nhtsa.js.map +1 -0
  83. package/dist/nist-controls.d.ts +48 -0
  84. package/dist/nist-controls.d.ts.map +1 -0
  85. package/dist/nist-controls.js +174 -0
  86. package/dist/nist-controls.js.map +1 -0
  87. package/dist/nonprofit.d.ts +116 -0
  88. package/dist/nonprofit.d.ts.map +1 -0
  89. package/dist/nonprofit.js +342 -0
  90. package/dist/nonprofit.js.map +1 -0
  91. package/dist/nws-weather.d.ts +57 -0
  92. package/dist/nws-weather.d.ts.map +1 -0
  93. package/dist/nws-weather.js +131 -0
  94. package/dist/nws-weather.js.map +1 -0
  95. package/dist/openfda-device.d.ts +85 -0
  96. package/dist/openfda-device.d.ts.map +1 -0
  97. package/dist/openfda-device.js +277 -0
  98. package/dist/openfda-device.js.map +1 -0
  99. package/dist/openfda-drugsfda.d.ts +72 -0
  100. package/dist/openfda-drugsfda.d.ts.map +1 -0
  101. package/dist/openfda-drugsfda.js +230 -0
  102. package/dist/openfda-drugsfda.js.map +1 -0
  103. package/dist/openfda.d.ts +133 -0
  104. package/dist/openfda.d.ts.map +1 -0
  105. package/dist/openfda.js +425 -0
  106. package/dist/openfda.js.map +1 -0
  107. package/dist/server.d.ts.map +1 -1
  108. package/dist/server.js +996 -16
  109. package/dist/server.js.map +1 -1
  110. package/dist/treasury.d.ts +2 -0
  111. package/dist/treasury.d.ts.map +1 -1
  112. package/dist/treasury.js +7 -0
  113. package/dist/treasury.js.map +1 -1
  114. package/dist/usaspending.d.ts +32 -1
  115. package/dist/usaspending.d.ts.map +1 -1
  116. package/dist/usaspending.js +143 -16
  117. package/dist/usaspending.js.map +1 -1
  118. package/package.json +3 -2
  119. package/src/bea.ts +1 -1
  120. package/src/cbp-border.ts +177 -0
  121. package/src/census-economic.ts +12 -6
  122. package/src/cms-facility.ts +379 -0
  123. package/src/cms-hospital.ts +344 -0
  124. package/src/cms-supplier.ts +527 -0
  125. package/src/cms-utilization.ts +389 -0
  126. package/src/courtlistener.ts +465 -0
  127. package/src/cpsc.ts +333 -0
  128. package/src/datagov-catalog.ts +18 -2
  129. package/src/dol.ts +5 -5
  130. package/src/ecfr.ts +27 -10
  131. package/src/edgar.ts +39 -7
  132. package/src/epa-envirofacts.ts +358 -0
  133. package/src/errors.ts +11 -0
  134. package/src/far.ts +3 -1
  135. package/src/federal-register.ts +29 -10
  136. package/src/fema.ts +139 -0
  137. package/src/fred.ts +1 -1
  138. package/src/gov-domains.ts +237 -0
  139. package/src/keys.ts +27 -6
  140. package/src/nhtsa.ts +352 -0
  141. package/src/nist-controls.ts +219 -0
  142. package/src/nonprofit.ts +460 -0
  143. package/src/nws-weather.ts +167 -0
  144. package/src/openfda-device.ts +356 -0
  145. package/src/openfda-drugsfda.ts +313 -0
  146. package/src/openfda.ts +518 -0
  147. package/src/server.ts +1127 -27
  148. package/src/treasury.ts +7 -0
  149. package/src/usaspending.ts +189 -17
package/src/cpsc.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * cpsc.ts — CPSC CONSUMER-PRODUCT RECALLS (www.saferproducts.gov) — the consumer
3
+ * goods / import product-safety vetting lane (ADR-0058). ONE keyless tool:
4
+ * • cpsc_recalls — /RestWebServices/Recall?format=json + date/product/manufacturer/
5
+ * recallNumber filters.
6
+ * The third leg of the cross-agency product-safety family alongside NHTSA (vehicles)
7
+ * and openFDA (medical/food): a manufacturer / product / hazard recall history for
8
+ * B2G supplier and import vetting.
9
+ *
10
+ * ★ KEYLESS — there is NO API key at all (no parameter, no header). This module
11
+ * touches NO key seam (no KEY_REGISTRY / keys.ts / API_KEYS.md). It REUSES the
12
+ * shared `getJson` (redirect:"error") / `driftError` fetch envelope, the `str`
13
+ * coercion (null-never-empty-string), and `withMeta`/`buildMeta` — and mirrors
14
+ * nhtsa.ts / datagov-catalog.ts's fixed-host SSRF idiom + schema_drift
15
+ * catch-ladder verbatim.
16
+ *
17
+ * ★ THE HONESTY PILLARS (P1-P5, live-verified 2026-07-15):
18
+ * P1: the /Recall response is a BARE JSON ARRAY with NO count field and NO server
19
+ * pagination — it returns the COMPLETE set matching the filter. So
20
+ * totalAvailable = results.length and complete:true, WITH a disclosing note
21
+ * that CPSC reports no total-count field / no pagination. A total is NEVER
22
+ * fabricated (there is no upstream total to trust; the honest total is the
23
+ * length of the complete set).
24
+ * P2: an empty array `[]` ⇒ an HONEST EMPTY (returned:0, complete:true) — a filter
25
+ * that matches nothing is an honest no-match, NOT an error. A 4xx ⇒
26
+ * invalid_input; a 5xx/timeout ⇒ THROW (never a fake empty); a 200 non-JSON
27
+ * body OR a non-array body ⇒ schema_drift (never a fabricated empty).
28
+ * P3: dates stay STRINGS (via `str`); NumberOfUnits is free text ("About 6,500")
29
+ * kept as a STRING; nested arrays (Products/Manufacturers/Retailers/Hazards/
30
+ * Remedies/Injuries/ManufacturerCountries) are flattened to string arrays by
31
+ * extracting each object's `.Name` (★ManufacturerCountries uses `.Country`,
32
+ * NOT `.Name`), SKIPPING an empty `{}` object (never a fabricated entry); a
33
+ * genuinely-absent nested array ⇒ []; null-never-empty-string throughout.
34
+ * P4: the top-level body MUST be an array — a non-array (object/string/null) ⇒
35
+ * driftError (a broken response contract, never a fabricated empty).
36
+ * DEFAULT-WINDOW: with NO filter given, the unfiltered result is huge, so the tool
37
+ * defaults RecallDateStart to ~90 days ago and DISCLOSES the default in a note
38
+ * — it NEVER silently fetches the whole dataset.
39
+ * SSRF: fixed host `www.saferproducts.gov` (compile-time literal) + post-construction
40
+ * hostname/protocol assertion + redirect:"error"; every filter rides a
41
+ * module-built URLSearchParams (no raw passthrough); dates are ^\d{4}-\d{2}-\d{2}$;
42
+ * recallNumber is charclass-validated (letters/digits/hyphen only), so a
43
+ * `../` or `%` can never reach the fixed path.
44
+ */
45
+
46
+ import { ToolErrorCarrier } from "./errors.js";
47
+ import { getJson, driftError } from "./datasource.js";
48
+ import { str } from "./coerce.js";
49
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
50
+
51
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
52
+ export const CPSC_HOST = "www.saferproducts.gov";
53
+ const CPSC_RECALL_PATH = "/RestWebServices/Recall";
54
+ // HOST+path-only label (→ ToolError.upstreamEndpoint). Keyless ⇒ no token can ever
55
+ // appear here regardless, but the label stays host+path for consistency.
56
+ const CPSC_RECALL_LABEL = "cpsc:/RestWebServices/Recall";
57
+
58
+ // ─── Input validation grammar (SSRF + injection guard) ────────────
59
+ // dates: strict YYYY-MM-DD. recallNumber: letters/digits/hyphen only — rejects
60
+ // `../`, `%`, `/`, `.`, spaces, quotes, so a value can never break out of the
61
+ // URLSearchParams-encoded query onto the fixed host/path.
62
+ export const CPSC_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
63
+ export const CPSC_RECALL_NUMBER_RE = /^[A-Za-z0-9-]+$/;
64
+
65
+ // The default recent-window span (days) applied when NO filter is given, so an
66
+ // unbounded whole-dataset fetch never happens silently.
67
+ const CPSC_DEFAULT_WINDOW_DAYS = 90;
68
+
69
+ const KEYLESS_NOTE =
70
+ "CPSC SaferProducts is a keyless public API (www.saferproducts.gov) — no API key is required or accepted.";
71
+ // The P1 load-bearing honesty caveat carried on EVERY response.
72
+ const CPSC_NO_PAGINATION_NOTE =
73
+ "CPSC returns the complete matching set (no server pagination or total-count field) — totalAvailable is the number of returned recalls (the size of the complete set), not an upstream-reported total.";
74
+
75
+ // ─── Nested-array flatteners (P3) ─────────────────────────────────
76
+ /**
77
+ * Flatten a CPSC nested array (Products/Manufacturers/Retailers/Hazards/Remedies/
78
+ * Injuries/ManufacturerCountries) to a string[] by extracting `field` from each
79
+ * object via `str` (null-never-empty-string). An empty `{}` object (or one whose
80
+ * `field` is absent/blank) is SKIPPED — never a fabricated entry. A non-array
81
+ * (absent nested array) ⇒ [].
82
+ */
83
+ function extractField(x: unknown, field: string): string[] {
84
+ if (!Array.isArray(x)) return [];
85
+ const out: string[] = [];
86
+ for (const el of x) {
87
+ if (el === null || typeof el !== "object") continue;
88
+ const v = str((el as Record<string, unknown>)[field]);
89
+ if (v !== null) out.push(v);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * The recall-level numberOfUnits (P3): CPSC carries NumberOfUnits per PRODUCT as
96
+ * FREE TEXT ("About 6,500"), so this returns the FIRST product's non-blank
97
+ * NumberOfUnits as a STRING (never numerically coerced), or null when none is
98
+ * present (null-never-empty-string).
99
+ */
100
+ function firstNumberOfUnits(products: unknown): string | null {
101
+ if (!Array.isArray(products)) return null;
102
+ for (const p of products) {
103
+ if (p === null || typeof p !== "object") continue;
104
+ const v = str((p as Record<string, unknown>).NumberOfUnits);
105
+ if (v !== null) return v;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // ─── Curated row shape ────────────────────────────────────────────
111
+ export type CpscRecall = {
112
+ recallNumber: string | null;
113
+ recallDate: string | null;
114
+ title: string | null;
115
+ description: string | null;
116
+ url: string | null;
117
+ products: string[];
118
+ numberOfUnits: string | null;
119
+ manufacturers: string[];
120
+ retailers: string[];
121
+ hazards: string[];
122
+ remedies: string[];
123
+ injuries: string[];
124
+ manufacturerCountries: string[];
125
+ };
126
+
127
+ /**
128
+ * Map ONE /Recall row → the curated recall shape. Scalars via `str`
129
+ * (null-never-empty-string; dates stay strings). Nested arrays flattened via
130
+ * `extractField` on `.Name` — EXCEPT ManufacturerCountries, which carries `.Country`.
131
+ */
132
+ function mapRecall(row: unknown): CpscRecall {
133
+ const r = (row ?? {}) as Record<string, unknown>;
134
+ return {
135
+ recallNumber: str(r.RecallNumber),
136
+ recallDate: str(r.RecallDate),
137
+ title: str(r.Title),
138
+ description: str(r.Description),
139
+ url: str(r.URL),
140
+ products: extractField(r.Products, "Name"),
141
+ numberOfUnits: firstNumberOfUnits(r.Products),
142
+ manufacturers: extractField(r.Manufacturers, "Name"),
143
+ retailers: extractField(r.Retailers, "Name"),
144
+ hazards: extractField(r.Hazards, "Name"),
145
+ remedies: extractField(r.Remedies, "Name"),
146
+ injuries: extractField(r.Injuries, "Name"),
147
+ // ★ ManufacturerCountries objects carry `Country`, not `Name` (live-verified).
148
+ manufacturerCountries: extractField(r.ManufacturerCountries, "Country"),
149
+ };
150
+ }
151
+
152
+ // ─── Input validation (belt-and-suspenders behind the server Zod) ──
153
+ export type CpscRecallsArgs = {
154
+ dateStart?: string;
155
+ dateEnd?: string;
156
+ productName?: string;
157
+ manufacturer?: string;
158
+ recallNumber?: string;
159
+ };
160
+
161
+ /**
162
+ * Validate the optional inputs PRE-fetch (0 network call). A DIRECT handler call
163
+ * bypasses the server Zod, so re-guard the SSRF-relevant grammars here: dates are
164
+ * ^\d{4}-\d{2}-\d{2}$; recallNumber is letters/digits/hyphen only. productName /
165
+ * manufacturer are free text (they ride URLSearchParams-encoded, so injection is
166
+ * neutralized by encoding — no charclass needed, but they are validated as strings).
167
+ */
168
+ function validateArgs(args: CpscRecallsArgs): void {
169
+ const dateChecks: Array<[string, string | undefined]> = [
170
+ ["dateStart", args.dateStart],
171
+ ["dateEnd", args.dateEnd],
172
+ ];
173
+ for (const [name, value] of dateChecks) {
174
+ if (value !== undefined && (typeof value !== "string" || !CPSC_DATE_RE.test(value))) {
175
+ throw new ToolErrorCarrier({
176
+ kind: "invalid_input",
177
+ retryable: false,
178
+ message: `Invalid ${name} ${JSON.stringify(value)} — expected a YYYY-MM-DD date (^\\d{4}-\\d{2}-\\d{2}$), e.g. "2025-01-01".`,
179
+ upstreamEndpoint: CPSC_RECALL_LABEL,
180
+ });
181
+ }
182
+ }
183
+ if (
184
+ args.recallNumber !== undefined &&
185
+ (typeof args.recallNumber !== "string" || !CPSC_RECALL_NUMBER_RE.test(args.recallNumber))
186
+ ) {
187
+ throw new ToolErrorCarrier({
188
+ kind: "invalid_input",
189
+ retryable: false,
190
+ message: `Invalid recallNumber ${JSON.stringify(args.recallNumber)} — expected letters/digits/hyphen only (^[A-Za-z0-9-]+$), e.g. "25088".`,
191
+ upstreamEndpoint: CPSC_RECALL_LABEL,
192
+ });
193
+ }
194
+ for (const [name, value] of [
195
+ ["productName", args.productName],
196
+ ["manufacturer", args.manufacturer],
197
+ ] as Array<[string, string | undefined]>) {
198
+ if (value !== undefined && typeof value !== "string") {
199
+ throw new ToolErrorCarrier({
200
+ kind: "invalid_input",
201
+ retryable: false,
202
+ message: `Invalid ${name} — expected a string.`,
203
+ upstreamEndpoint: CPSC_RECALL_LABEL,
204
+ });
205
+ }
206
+ }
207
+ }
208
+
209
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
210
+ /**
211
+ * GET the CPSC /Recall JSON on the FIXED host. Builds
212
+ * `https://www.saferproducts.gov/RestWebServices/Recall?${params}`, asserts the
213
+ * CONSTRUCTED URL's hostname === the fixed host over https (belt-and-suspenders),
214
+ * and sets `redirect:"error"` (fail closed on any off-host 3xx). Keyless — no
215
+ * header/token.
216
+ */
217
+ async function getCpsc(params: URLSearchParams): Promise<unknown> {
218
+ const url = `https://${CPSC_HOST}${CPSC_RECALL_PATH}?${params.toString()}`;
219
+ const built = new URL(url);
220
+ if (built.hostname !== CPSC_HOST || built.protocol !== "https:") {
221
+ throw new ToolErrorCarrier({
222
+ kind: "invalid_input",
223
+ retryable: false,
224
+ message: `Constructed CPSC URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(CPSC_HOST)} over https — refusing to fetch (SSRF safety).`,
225
+ upstreamEndpoint: CPSC_RECALL_LABEL,
226
+ });
227
+ }
228
+ return getJson(url, { label: CPSC_RECALL_LABEL, redirect: "error" });
229
+ }
230
+
231
+ /** Compute an ISO YYYY-MM-DD `days` days before now (the default-window start). */
232
+ function daysAgoIso(days: number): string {
233
+ return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
234
+ }
235
+
236
+ // ─── Tool: cpsc_recalls ───────────────────────────────────────────
237
+ /**
238
+ * Fetch CPSC consumer-product RECALLS → curated recall rows + honest `_meta`.
239
+ * KEYLESS. All filters are optional; with NO filter given, RecallDateStart defaults
240
+ * to ~90 days ago (disclosed in a note) so the whole dataset is never silently
241
+ * fetched. The response is a bare array with no total-count field / no pagination
242
+ * ⇒ totalAvailable = the number of returned recalls, complete:true. An empty array
243
+ * ⇒ an honest empty; a 4xx ⇒ invalid_input; a 5xx/timeout ⇒ THROW; a 200 non-JSON
244
+ * OR a non-array body ⇒ schema_drift.
245
+ */
246
+ export async function recalls(args: CpscRecallsArgs): Promise<MetaBundle> {
247
+ validateArgs(args);
248
+
249
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
250
+ // passthrough). format=json is ALWAYS appended. ──
251
+ const params = new URLSearchParams();
252
+ params.set("format", "json");
253
+ const filtersApplied: string[] = [];
254
+ if (args.dateStart !== undefined) {
255
+ params.set("RecallDateStart", args.dateStart);
256
+ filtersApplied.push("dateStart");
257
+ }
258
+ if (args.dateEnd !== undefined) {
259
+ params.set("RecallDateEnd", args.dateEnd);
260
+ filtersApplied.push("dateEnd");
261
+ }
262
+ if (args.productName !== undefined) {
263
+ params.set("ProductName", args.productName);
264
+ filtersApplied.push("productName");
265
+ }
266
+ if (args.manufacturer !== undefined) {
267
+ params.set("Manufacturer", args.manufacturer);
268
+ filtersApplied.push("manufacturer");
269
+ }
270
+ if (args.recallNumber !== undefined) {
271
+ params.set("RecallNumber", args.recallNumber);
272
+ filtersApplied.push("recallNumber");
273
+ }
274
+
275
+ // ── DEFAULT-WINDOW: with NO filter, an unbounded fetch would return the WHOLE
276
+ // dataset. Bound it to a ~90-day recent window (RecallDateStart) and DISCLOSE
277
+ // the default — never silently fetch everything. ──
278
+ const notes: string[] = [KEYLESS_NOTE, CPSC_NO_PAGINATION_NOTE];
279
+ let defaultWindowApplied = false;
280
+ if (filtersApplied.length === 0) {
281
+ const defaultStart = daysAgoIso(CPSC_DEFAULT_WINDOW_DAYS);
282
+ params.set("RecallDateStart", defaultStart);
283
+ defaultWindowApplied = true;
284
+ notes.push(
285
+ `No filter was provided — to avoid silently fetching the ENTIRE recall dataset, results are bounded to a default recent window: RecallDateStart=${defaultStart} (~${CPSC_DEFAULT_WINDOW_DAYS} days ago). Pass dateStart/dateEnd, productName, manufacturer, or recallNumber for a scoped query.`,
286
+ );
287
+ }
288
+
289
+ // ── The typed catch-ladder (nhtsa.ts / datagov-catalog.ts shape, VERBATIM):
290
+ // a ToolErrorCarrier (host-assert / 4xx-5xx taxonomy) rethrows FIRST
291
+ // (preserving its kind); a 200 non-JSON `.json()` SyntaxError reclassifies to
292
+ // schema_drift; a bare error rethrows LAST. ──
293
+ let body: unknown;
294
+ try {
295
+ body = await getCpsc(params);
296
+ } catch (e) {
297
+ if (e instanceof ToolErrorCarrier) throw e;
298
+ if (e instanceof SyntaxError)
299
+ throw driftError(
300
+ CPSC_RECALL_LABEL,
301
+ "CPSC /RestWebServices/Recall returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
302
+ );
303
+ throw e;
304
+ }
305
+
306
+ // ── P4: the top-level body MUST be an array (a non-array object/string/null is
307
+ // drift, never a fabricated empty). ──
308
+ if (!Array.isArray(body)) {
309
+ throw driftError(
310
+ CPSC_RECALL_LABEL,
311
+ "CPSC /RestWebServices/Recall shape drift — the response must be a bare JSON array.",
312
+ );
313
+ }
314
+
315
+ const recalls = (body as unknown[]).map(mapRecall);
316
+ const returned = recalls.length;
317
+
318
+ return withMeta(
319
+ { recalls },
320
+ {
321
+ source: `${CPSC_HOST} /RestWebServices/Recall (CPSC consumer-product recalls; keyless)`,
322
+ keylessMode: true,
323
+ returned,
324
+ // P1 — no upstream total-count field / no pagination: the complete set IS the
325
+ // returned rows, so totalAvailable = returned and complete:true (derived).
326
+ totalAvailable: returned,
327
+ filtersApplied: defaultWindowApplied ? ["dateStart(default)"] : filtersApplied,
328
+ filtersDropped: [],
329
+ fieldsUnavailable: [],
330
+ notes,
331
+ } satisfies Partial<ResponseMeta>,
332
+ );
333
+ }
@@ -209,14 +209,19 @@ export async function searchDatasets(
209
209
  const params = new URLSearchParams();
210
210
  const filtersApplied: string[] = [];
211
211
  if (args.query !== undefined) {
212
- params.set("_q", args.query);
212
+ // DRIFT FIX (dogfooding 2026-07-16): the data.gov v4 catalog renamed its
213
+ // free-text param `_q` → `q` (and `_size` → `size`). The old `_q` was SILENTLY
214
+ // IGNORED — every query returned the same default catalog page while
215
+ // filtersApplied still claimed "query", a confidently-wrong result. LIVE-VERIFIED:
216
+ // q=wildfire → wildfire datasets; _q=wildfire → the generic default list.
217
+ params.set("q", args.query);
213
218
  filtersApplied.push("query");
214
219
  }
215
220
  if (args.organization !== undefined) {
216
221
  params.set("organization", args.organization);
217
222
  filtersApplied.push("organization");
218
223
  }
219
- params.set("_size", String(limit));
224
+ params.set("size", String(limit));
220
225
  if (args.cursor !== undefined) {
221
226
  params.set("after", args.cursor);
222
227
  filtersApplied.push("cursor");
@@ -271,6 +276,17 @@ export async function searchDatasets(
271
276
  "No filters were applied — this is an unscoped scan of the WHOLE data.gov catalog. Add `query` and/or `organization` for a meaningful scoped result set.",
272
277
  );
273
278
  }
279
+ // Behavior-driven limit-honesty (dogfooding 2026-07-16): the v4 catalog currently
280
+ // returns an upstream-FIXED page (~20) and ignores the `size` argument. Fire ONLY
281
+ // when the API returned MORE than requested (returned > limit) — a DEFINITIVE
282
+ // "limit ignored" signal that can't be confused with a genuine small result set
283
+ // (returned < limit could just be few matches). Behavior-driven, so if the API
284
+ // begins honoring `size` again (returned ≤ limit) the note self-suppresses.
285
+ if (returned > limit) {
286
+ notes.push(
287
+ `The requested limit (${limit}) was NOT honored — the data.gov v4 catalog returned MORE (${returned} rows): it currently serves an upstream-fixed page and ignores the size argument. Page through the full result set with _meta.nextCursor, not by raising limit.`,
288
+ );
289
+ }
274
290
 
275
291
  return withMeta(
276
292
  { datasets },
package/src/dol.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * • `dol_get_dataset` (KEY-REQUIRED, DOL_API_KEY) — GET
13
13
  * /v4/get/{agency}/{endpoint}/json?… . The DATA endpoint has NO keyless tier, so
14
14
  * with NO `DOL_API_KEY` this tool THROWS an invalid_input config error BEFORE any
15
- * fetch (0 network call; the message names DOL_API_KEY + dol.gov/developer).
15
+ * fetch (0 network call; the message names DOL_API_KEY + dataportal.dol.gov/registration).
16
16
  * So DOL is the 4th REQUIRED key — but ONLY for the data tool; the catalog tool
17
17
  * (and every other tool on the server) stays keyless.
18
18
  *
@@ -44,7 +44,7 @@
44
44
  *
45
45
  * ★HONESTY (ADR-0053 P1–P4 + KEY + SSRF):
46
46
  * [KEY] dol_get_dataset with NO DOL_API_KEY ⇒ invalid_input THROW pre-fetch (0
47
- * fetch); the message names DOL_API_KEY + dol.gov/developer. The key rides the
47
+ * fetch); the message names DOL_API_KEY + dataportal.dol.gov/registration. The key rides the
48
48
  * `X-API-KEY` HEADER ONLY — NEVER the URL / label / _meta / notes / a log (the
49
49
  * K-test). dol_list_datasets is keyless (no key read, no header).
50
50
  * [P1] catalog: totalAvailable = meta.total_count (the API's real catalog total)
@@ -103,7 +103,7 @@ const DEFAULT_LIST_LIMIT = 25;
103
103
 
104
104
  // ─── Honesty notes ────────────────────────────────────────────────
105
105
  const KEY_REQUIRED_NOTE =
106
- "dol_get_dataset REQUIRES a free DOL_API_KEY (the DOL data endpoint has no keyless tier; the CATALOG — dol_list_datasets — and agency list are keyless). Get a key at https://dol.gov/developer. The key is sent ONLY in the X-API-KEY request header and is NEVER logged, echoed, or placed in this response.";
106
+ "dol_get_dataset REQUIRES a free DOL_API_KEY (the DOL data endpoint has no keyless tier; the CATALOG — dol_list_datasets — and agency list are keyless). Get a key at https://dataportal.dol.gov/registration. The key is sent ONLY in the X-API-KEY request header and is NEVER logged, echoed, or placed in this response.";
107
107
  const DATA_ENVELOPE_NOTE =
108
108
  "The DOL data-record envelope is key-gated and could not be verified live, so records are returned VERBATIM (each dataset has its own enforcement schema — field names and values are preserved as-is; a value is NOT coerced, so a genuine 0 stays 0 and a missing field stays null).";
109
109
  const DATA_NO_TOTAL_NOTE =
@@ -313,7 +313,7 @@ export async function getDataset(args: DolGetDatasetArgs): Promise<MetaBundle> {
313
313
  kind: "invalid_input",
314
314
  retryable: false,
315
315
  message:
316
- "The DOL data endpoint requires a free DOL_API_KEY (the dataset CATALOG, dol_list_datasets, is keyless). Get one at https://dol.gov/developer and set DOL_API_KEY.",
316
+ "The DOL data endpoint requires a free DOL_API_KEY (the dataset CATALOG, dol_list_datasets, is keyless). Get one at https://dataportal.dol.gov/registration and set DOL_API_KEY.",
317
317
  upstreamEndpoint: label,
318
318
  });
319
319
  }
@@ -404,7 +404,7 @@ export async function getDataset(args: DolGetDatasetArgs): Promise<MetaBundle> {
404
404
  kind: "invalid_input",
405
405
  retryable: false,
406
406
  message:
407
- "DOL rejected the request as unauthorized (HTTP 401/403) — DOL_API_KEY is missing or invalid. Check the key (free at https://dol.gov/developer).",
407
+ "DOL rejected the request as unauthorized (HTTP 401/403) — DOL_API_KEY is missing or invalid. Check the key (free at https://dataportal.dol.gov/registration).",
408
408
  upstreamStatus: status,
409
409
  upstreamEndpoint: label,
410
410
  });
package/src/ecfr.ts CHANGED
@@ -52,16 +52,33 @@ export async function listTitles() {
52
52
  }[];
53
53
  };
54
54
  const json = await fetchJson<Resp>(`${ECFR}/versioner/v1/titles.json`);
55
- return {
56
- titles: (json.titles ?? []).map((t) => ({
57
- number: t.number ?? 0,
58
- name: t.name ?? "",
59
- latestAmendedOn: t.latest_amended_on,
60
- latestIssueDate: t.latest_issue_date,
61
- upToDateAsOf: t.up_to_date_as_of,
62
- reserved: !!t.reserved,
63
- })),
64
- };
55
+ const titles = (json.titles ?? []).map((t) => ({
56
+ number: t.number ?? 0,
57
+ name: t.name ?? "",
58
+ latestAmendedOn: t.latest_amended_on,
59
+ latestIssueDate: t.latest_issue_date,
60
+ upToDateAsOf: t.up_to_date_as_of,
61
+ reserved: !!t.reserved,
62
+ }));
63
+ // Carry the honesty envelope every other tool has (dogfooding 2026-07-15: this
64
+ // list tool previously returned a bare {titles} with no _meta). The titles
65
+ // endpoint returns the COMPLETE canonical CFR title set in one response — no
66
+ // pagination, no filter — so the count IS the total and complete derives true.
67
+ return withMeta(
68
+ { titles },
69
+ {
70
+ source: "ecfr.gov/api (versioner/v1/titles)",
71
+ keylessMode: true,
72
+ returned: titles.length,
73
+ totalAvailable: titles.length,
74
+ truncated: false,
75
+ filtersApplied: [],
76
+ filtersDropped: [],
77
+ notes: [
78
+ "Complete canonical list of all CFR titles — the endpoint returns every title in a single response (no pagination or filtering).",
79
+ ],
80
+ },
81
+ );
65
82
  });
66
83
  }
67
84
 
package/src/edgar.ts CHANGED
@@ -241,20 +241,46 @@ async function tickerMap(): Promise<TickerEntry[]> {
241
241
  */
242
242
  async function resolveCik(
243
243
  cikOrTicker: string,
244
- ): Promise<{ cik: string; ticker: string | null; title: string | null } | null> {
244
+ ): Promise<{
245
+ cik: string;
246
+ ticker: string | null;
247
+ title: string | null;
248
+ matchedBy: "cik" | "ticker" | "title";
249
+ } | null> {
245
250
  const raw = cikOrTicker.trim();
246
251
  if (/^(cik)?\s*\d+$/i.test(raw)) {
247
- return { cik: padCik(raw), ticker: null, title: null };
252
+ return { cik: padCik(raw), ticker: null, title: null, matchedBy: "cik" };
248
253
  }
249
254
  const map = await tickerMap();
250
255
  const upper = raw.toUpperCase();
251
256
  const exact = map.find((e) => e.ticker.toUpperCase() === upper);
252
- if (exact) return { cik: exact.cik, ticker: exact.ticker, title: exact.title };
257
+ if (exact)
258
+ return { cik: exact.cik, ticker: exact.ticker, title: exact.title, matchedBy: "ticker" };
253
259
  const byName = map.find((e) => e.title.toUpperCase().includes(upper));
254
- if (byName) return { cik: byName.cik, ticker: byName.ticker, title: byName.title };
260
+ if (byName)
261
+ return { cik: byName.cik, ticker: byName.ticker, title: byName.title, matchedBy: "title" };
255
262
  return null;
256
263
  }
257
264
 
265
+ /**
266
+ * Disclosure note when resolveCik fell back to a case-insensitive TITLE SUBSTRING
267
+ * match (not an exact ticker/CIK). A substring silently resolves to the FIRST filer
268
+ * whose title contains the string — which can be the WRONG company (dogfooding
269
+ * 2026-07-16: "MICRO" → MICROSOFT, not Micron/AMD/Super Micro), and the data tools
270
+ * would then return that filer's financials as authoritative with no caveat. The
271
+ * data-returning tools (facts/filings/concept) MUST surface HOW the id resolved —
272
+ * exactly as edgar_lookup_cik already does. Returns null for exact ticker/CIK matches.
273
+ */
274
+ function fuzzyResolveNote(
275
+ resolved: { title: string | null; cik: string; matchedBy: string },
276
+ input: string,
277
+ ): string | null {
278
+ if (resolved.matchedBy !== "title") return null;
279
+ return `Resolved "${input}" to ${
280
+ resolved.title ?? "this filer"
281
+ } (CIK ${resolved.cik}) by a case-insensitive TITLE SUBSTRING match, NOT an exact ticker/CIK — a substring can silently match the WRONG company. VERIFY this is the intended filer (use edgar_lookup_cik to list all matches, or pass an exact ticker or 10-digit CIK).`;
282
+ }
283
+
258
284
  // ─── meta helper ──────────────────────────────────────────────────
259
285
  /**
260
286
  * Build a partial `_meta` with the EDGAR source + the mandatory CIK↔UEI caveat
@@ -549,6 +575,7 @@ export async function companyFilings(args: {
549
575
  );
550
576
  }
551
577
  const cik = resolved.cik;
578
+ const fuzzyNote = fuzzyResolveNote(resolved, args.cikOrTicker);
552
579
  let subm;
553
580
  try {
554
581
  subm = await fetchSubmissions(cik);
@@ -746,7 +773,7 @@ export async function companyFilings(args: {
746
773
  hasMore,
747
774
  nextOffset,
748
775
  },
749
- notes,
776
+ notes: fuzzyNote ? [fuzzyNote, ...notes] : notes,
750
777
  }),
751
778
  );
752
779
  }
@@ -833,6 +860,7 @@ export async function companyFacts(args: {
833
860
  );
834
861
  }
835
862
  const cik = resolved.cik;
863
+ const fuzzyNote = fuzzyResolveNote(resolved, args.cikOrTicker);
836
864
  let doc: FactsDoc;
837
865
  try {
838
866
  doc = await fetchFacts(cik);
@@ -964,7 +992,7 @@ export async function companyFacts(args: {
964
992
  totalAvailable: concepts.length,
965
993
  complete: true,
966
994
  filtersApplied: latest ? ["concepts", "unit", "latest"] : ["concepts", "unit"],
967
- notes,
995
+ notes: fuzzyNote ? [fuzzyNote, ...notes] : notes,
968
996
  }),
969
997
  );
970
998
  }
@@ -2771,6 +2799,7 @@ export async function companyConcept(args: {
2771
2799
  );
2772
2800
  }
2773
2801
  const cik = resolved.cik;
2802
+ const fuzzyNote = fuzzyResolveNote(resolved, args.cikOrTicker);
2774
2803
  const taxonomy = args.taxonomy ?? "us-gaap";
2775
2804
  const concept = args.concept;
2776
2805
 
@@ -2873,6 +2902,7 @@ export async function companyConcept(args: {
2873
2902
  complete: true,
2874
2903
  filtersApplied: ["concept", "taxonomy"],
2875
2904
  notes: [
2905
+ ...(fuzzyNote ? [fuzzyNote] : []),
2876
2906
  `The companyconcept document for CIK ${cik} / ${taxonomy} / ${concept} has no reported data points (units{} is empty). This is an honest empty — NOT a value of 0 and NOT an outage.`,
2877
2907
  ],
2878
2908
  }),
@@ -2943,7 +2973,9 @@ export async function companyConcept(args: {
2943
2973
  if (fyFilter !== null) filtersApplied.push("fy");
2944
2974
  if (canonicalOnly) filtersApplied.push("canonicalOnly");
2945
2975
 
2946
- const notes: string[] = [AMENDMENT_DISCLOSURE_NOTE];
2976
+ const notes: string[] = fuzzyNote
2977
+ ? [fuzzyNote, AMENDMENT_DISCLOSURE_NOTE]
2978
+ : [AMENDMENT_DISCLOSURE_NOTE];
2947
2979
 
2948
2980
  // Unit-filter disclosure (Q2-c) — NEVER hide the other units.
2949
2981
  if (unitFilter !== null) {