@cyanheads/cpsc-recalls-mcp-server 0.1.8 → 0.2.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 (28) hide show
  1. package/AGENTS.md +8 -6
  2. package/CLAUDE.md +8 -6
  3. package/README.md +13 -11
  4. package/changelog/0.2.x/0.2.0.md +31 -0
  5. package/dist/index.js +1 -5
  6. package/dist/index.js.map +1 -1
  7. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.d.ts +4 -4
  8. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.js +16 -34
  10. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/cpsc-get-recent.tool.d.ts +6 -4
  12. package/dist/mcp-server/tools/definitions/cpsc-get-recent.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/cpsc-get-recent.tool.js +142 -115
  14. package/dist/mcp-server/tools/definitions/cpsc-get-recent.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.d.ts +12 -9
  16. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.js +366 -244
  18. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/response-budget.d.ts +34 -0
  20. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  21. package/dist/mcp-server/tools/response-budget.js +53 -0
  22. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  23. package/dist/services/cpsc-recall/cpsc-recall-service.d.ts +1 -1
  24. package/dist/services/cpsc-recall/cpsc-recall-service.d.ts.map +1 -1
  25. package/dist/services/cpsc-recall/cpsc-recall-service.js +4 -5
  26. package/dist/services/cpsc-recall/cpsc-recall-service.js.map +1 -1
  27. package/package.json +2 -2
  28. package/server.json +3 -3
@@ -5,26 +5,253 @@
5
5
  */
6
6
  import { tool, z } from '@cyanheads/mcp-ts-core';
7
7
  import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
8
+ import { budgetCutNotice, countWithinBudget, pageOverheadBytes, } from '../response-budget.js';
8
9
  import { getCpscRecallService } from '../../../services/cpsc-recall/cpsc-recall-service.js';
9
- /** Static jurisdiction note included in every response. */
10
- const JURISDICTION = 'CPSC covers consumer products — toys, electronics, furniture, appliances, tools, clothing. ' +
11
- 'Does NOT cover: food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), firearms (ATF). ' +
12
- 'For those categories, use the appropriate server.';
10
+ /**
11
+ * Static jurisdiction note included in every response — the text the other two tools
12
+ * carry, plus a routing sentence.
13
+ */
14
+ const JURISDICTION = 'CPSC covers consumer products — toys, electronics, furniture, appliances, tools, clothing. Does NOT cover: food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), firearms (ATF). For those categories, use the appropriate server.';
13
15
  /** Static provenance caveat included in every response. */
14
- const SOURCE_NOTE = 'Recall fields are relayed verbatim from the CPSC record and are neither edited nor verified by this server. ' +
15
- 'CPSC records occasionally carry missing or inconsistent text. ' +
16
- 'Check cpsc_url before acting on a recall for a consumer-facing decision.';
16
+ const SOURCE_NOTE = 'Recall fields are relayed verbatim from the CPSC record and are neither edited nor verified by this server. CPSC records occasionally carry missing or inconsistent text. Check cpsc_url before acting on a recall for a consumer-facing decision.';
17
+ /**
18
+ * Sent as `RecallDateStart` when a search would otherwise send no upstream parameter.
19
+ * The unfiltered collection has been observed to lag the filtered responses, omitting the
20
+ * newest recalls that any filtered request returns. The floor precedes the oldest CPSC
21
+ * recall date (1973-06-08), so it never excludes a record.
22
+ */
23
+ const RECALL_DATE_FLOOR = '1970-01-01';
24
+ const names = (entries) => entries.map((entry) => entry.Name);
25
+ /** Every free-text filter, in the order the effective query lists them. */
26
+ const TEXT_FILTERS = [
27
+ { key: 'product_name', param: 'ProductName', text: (r) => names(r.Products) },
28
+ { key: 'manufacturer', param: 'Manufacturer', text: (r) => names(r.Manufacturers) },
29
+ { key: 'retailer', param: 'Retailer', text: (r) => names(r.Retailers) },
30
+ { key: 'importer', param: 'Importer', text: (r) => names(r.Importers) },
31
+ { key: 'distributor', param: 'Distributor', text: (r) => names(r.Distributors) },
32
+ { key: 'title_search', param: 'RecallTitle', text: (r) => [r.Title] },
33
+ { key: 'description_search', param: 'RecallDescription', text: (r) => [r.Description] },
34
+ { key: 'remedy', param: 'Remedy', text: (r) => names(r.Remedies) },
35
+ /** No upstream parameter: CPSC recognizes `Hazard` but never matches it. */
36
+ {
37
+ key: 'hazard_search',
38
+ text: (r) => [...names(r.Hazards), ...names(r.Products), ...names(r.Remedies)],
39
+ },
40
+ ];
41
+ const DATE_BOUNDS = ['date_start', 'date_end', 'updated_start', 'updated_end'];
42
+ /** Lowercases and maps curly apostrophes to `'`, so `'`, `’`, and `‘` match each other. */
43
+ function fold(text) {
44
+ return text.toLowerCase().replace(/[‘’]/g, "'");
45
+ }
46
+ /**
47
+ * Cap on a forwarded word's URL-encoded length. CPSC refuses a query string over 2,048
48
+ * bytes; eight words at this cap plus every date bound come to about 1,830.
49
+ */
50
+ const MAX_UPSTREAM_WORD_BYTES = 200;
51
+ /**
52
+ * The one word a filter sends upstream: its longest fragment free of apostrophes and
53
+ * brackets, the first on a tie, cut to the code points that fit `MAX_UPSTREAM_WORD_BYTES`
54
+ * once URL-encoded. CPSC matches a parameter as one contiguous substring and reads `%`,
55
+ * `_`, and `[…]` as wildcards, so every word — this one included, in full — is also
56
+ * checked locally. `%`, `_`, and a shortened word can only widen the fetch; a bracket
57
+ * class can exclude the record that holds the brackets literally, so no bracket is sent.
58
+ */
59
+ function upstreamWord(value) {
60
+ let longest = '';
61
+ for (const fragment of value.split(/[\s'‘’[\]]+/)) {
62
+ if (fragment.length > longest.length)
63
+ longest = fragment;
64
+ }
65
+ let word = '';
66
+ let bytes = 0;
67
+ for (const char of longest) {
68
+ bytes += new URLSearchParams({ w: char }).toString().length - 2;
69
+ if (bytes > MAX_UPSTREAM_WORD_BYTES)
70
+ break;
71
+ word += char;
72
+ }
73
+ return word || undefined;
74
+ }
75
+ /** True when every word of `filter` appears in its field; words may land in different entries. */
76
+ function matchesEveryWord(recall, filter) {
77
+ const haystack = fold(filter.text(recall).join('\n'));
78
+ return filter.words.every((word) => haystack.includes(word));
79
+ }
80
+ const recallSchema = z
81
+ .object({
82
+ recall_number: z
83
+ .string()
84
+ .describe('Recall identifier (5-digit numeric for 2002+ records, e.g. "25043"; 6-char with letter suffix for 1998–2001 records, e.g. "99003a"). Pass to cpsc_get_recall for full detail.'),
85
+ recall_date: z.string().describe('Date the recall was issued, ISO 8601.'),
86
+ title: z.string().describe('Official recall title.'),
87
+ hazards: z
88
+ .array(z.string().describe('Hazard description — what is dangerous.'))
89
+ .describe('Hazard descriptions — what is dangerous about this product.'),
90
+ remedy_options: z
91
+ .array(z.string().describe('Remedy type.'))
92
+ .describe('Remedy types: Refund, Repair, Replace, New Instructions, Dispose, Label, No Remedy Available, Inspect. Multiple may apply. Often empty — CPSC classified the remedy on fewer than half its records; read remedy_summary when this is empty, and cpsc_url when that is empty too.'),
93
+ remedy_summary: z
94
+ .string()
95
+ .describe('Full remedy instructions — what the consumer should do and how to claim.'),
96
+ products: z
97
+ .array(z
98
+ .object({
99
+ name: z.string().describe('Product name.'),
100
+ units_recalled: z
101
+ .string()
102
+ .describe('Estimated number of units recalled, e.g. "About 2,500".'),
103
+ })
104
+ .describe('A product covered by this recall.'))
105
+ .describe('Products covered by this recall. A recall may cover multiple products.'),
106
+ upcs: z
107
+ .array(z.string().describe('UPC code.'))
108
+ .describe('UPC codes for this recall (sparse — ~4% of records have UPCs). CPSC lists UPCs for the recall as a whole, not per product, so on a recall covering several products a UPC cannot be tied to one of them.'),
109
+ manufacturers: z
110
+ .array(z.string().describe('Manufacturer name.'))
111
+ .describe('Manufacturer names. Often empty — importer or retailer may be listed instead.'),
112
+ importers: z.array(z.string().describe('Importer name.')).describe('Importer company names.'),
113
+ retailers: z
114
+ .array(z.string().describe('Retailer name and sale details.'))
115
+ .describe('Retailer names and sale details (narrative text including stores, dates, price range).'),
116
+ cpsc_url: z.string().describe('Official CPSC recall page URL for human verification.'),
117
+ images: z
118
+ .array(z
119
+ .object({
120
+ url: z.string().describe('Image URL.'),
121
+ caption: z.string().describe('Image caption.'),
122
+ })
123
+ .describe('An image from the recall notice.'))
124
+ .describe('Product images from the recall notice.'),
125
+ data_quality_notes: z
126
+ .array(z.string().describe('One gap found in the CPSC record.'))
127
+ .describe('Gaps this server observed in the CPSC record — absent hazard text, absent product entries. Derived from which fields CPSC left empty, not from any judgement about the recall itself. Empty when nothing is missing.'),
128
+ })
129
+ .describe('A CPSC recall record.');
130
+ /** Shapes one upstream record for the response. */
131
+ function toSearchRecall(r) {
132
+ const hazards = r.Hazards.map((h) => h.Name).filter(Boolean);
133
+ const products = r.Products.map((p) => ({
134
+ name: p.Name,
135
+ units_recalled: p.NumberOfUnits ?? '',
136
+ }));
137
+ const data_quality_notes = [];
138
+ if (hazards.length === 0) {
139
+ data_quality_notes.push('CPSC listed no hazard description for this recall.');
140
+ }
141
+ if (products.length === 0) {
142
+ data_quality_notes.push('CPSC listed no product entries for this recall.');
143
+ }
144
+ return {
145
+ recall_number: r.RecallNumber,
146
+ recall_date: r.RecallDate.slice(0, 10),
147
+ title: r.Title,
148
+ hazards,
149
+ remedy_options: r.RemedyOptions.map((o) => o.Option).filter(Boolean),
150
+ remedy_summary: r.Remedies.map((rem) => rem.Name)
151
+ .filter(Boolean)
152
+ .join(' '),
153
+ products,
154
+ upcs: r.ProductUPCs.map((u) => u.UPC).filter(Boolean),
155
+ manufacturers: r.Manufacturers.map((m) => m.Name).filter(Boolean),
156
+ importers: r.Importers.map((i) => i.Name).filter(Boolean),
157
+ retailers: r.Retailers.map((ret) => ret.Name).filter(Boolean),
158
+ cpsc_url: r.URL,
159
+ images: r.Images.map((img) => ({ url: img.URL, caption: img.Caption })),
160
+ data_quality_notes,
161
+ };
162
+ }
163
+ /**
164
+ * One recall's `content[]` block. `format()` and the response budget both call it, so the
165
+ * bytes charged for a record are the bytes rendered for it.
166
+ */
167
+ function renderRecallBlock(r) {
168
+ const lines = [];
169
+ lines.push(`## [${r.recall_number}] — ${r.title} (${r.recall_date})`);
170
+ lines.push('CPSC source fields:');
171
+ const hazardText = r.hazards.length > 0 ? r.hazards.join('; ') : 'Not specified';
172
+ lines.push(`**Hazard:** ${hazardText}`);
173
+ const remedyTypes = r.remedy_options.length > 0 ? r.remedy_options.join(', ') : 'Not specified';
174
+ const remedyText = r.remedy_summary || 'See CPSC recall page.';
175
+ lines.push(`**Remedy:** ${remedyTypes} — ${remedyText}`);
176
+ const productNames = r.products
177
+ .map((p) => `${p.name} (${p.units_recalled || 'units not specified'})`)
178
+ .join('; ');
179
+ lines.push(`**Products:** ${productNames || 'Not specified'}`);
180
+ if (r.upcs.length > 0) {
181
+ lines.push(`**UPCs:** ${r.upcs.join(', ')}`);
182
+ }
183
+ const soldBy = r.retailers.length > 0 ? r.retailers.join('; ') : 'Not specified';
184
+ lines.push(`**Sold by:** ${soldBy}`);
185
+ /**
186
+ * Manufacturer and importer are distinct roles and CPSC org names contain commas,
187
+ * so each role gets its own line and entries are separated with '; '.
188
+ */
189
+ if (r.manufacturers.length > 0) {
190
+ lines.push(`**Manufacturer:** ${r.manufacturers.join('; ')}`);
191
+ }
192
+ if (r.importers.length > 0) {
193
+ lines.push(`**Importer:** ${r.importers.join('; ')}`);
194
+ }
195
+ if (r.manufacturers.length === 0 && r.importers.length === 0) {
196
+ lines.push('**Manufacturer/Importer:** Not specified');
197
+ }
198
+ if (r.images.length > 0) {
199
+ const imgList = r.images.map((img) => `${img.caption}: ${img.url}`).join('; ');
200
+ lines.push(`**Images (${r.images.length}):** ${imgList}`);
201
+ }
202
+ else {
203
+ lines.push(`**Images:** None`);
204
+ }
205
+ lines.push(`[View recall](${r.cpsc_url})`);
206
+ if (r.data_quality_notes.length > 0) {
207
+ lines.push(`**Data quality (server-assessed):** ${r.data_quality_notes.join(' ')}`);
208
+ }
209
+ lines.push('---');
210
+ return lines.join('\n');
211
+ }
212
+ /** The text after the record blocks, for a page that returned `returned` records. */
213
+ function renderFooter(page, returned) {
214
+ const truncNote = page.truncated ? ' (truncated)' : '';
215
+ const paging = page.has_more
216
+ ? ` More available — repeat with offset ${page.offset + returned}.`
217
+ : '';
218
+ const window = page.total_found === 0
219
+ ? 'No recalls matched the search criteria.'
220
+ : `Showing ${returned} of ${page.total_found} recalls${truncNote}, starting at offset ${page.offset}.${paging}`;
221
+ return [
222
+ window,
223
+ `Source: ${page.source_note}`,
224
+ `CPSC jurisdiction: ${page.cpsc_jurisdiction}`,
225
+ ].join('\n');
226
+ }
227
+ /**
228
+ * Why nothing matched, and which criterion to relax first. A suggestion never drops the
229
+ * only criterion, since a search with none fails `missing_criteria`.
230
+ */
231
+ function zeroMatchNotice(filters, matchedBeforeHazard, hasDateBound) {
232
+ const parts = ['No recalls matched every search criterion.'];
233
+ const hazardOnly = filters.length === 1 && filters[0]?.key === 'hazard_search' && !hasDateBound;
234
+ if (hazardOnly) {
235
+ parts.push("No recall's hazard descriptions, product names, or remedy instructions contain every hazard_search word — try another hazard term.");
236
+ }
237
+ else if (filters.some((f) => f.key === 'hazard_search') && matchedBeforeHazard > 0) {
238
+ parts.push(`${matchedBeforeHazard} recall${matchedBeforeHazard === 1 ? '' : 's'} matched before hazard_search was applied, and hazard_search removed all of them — try another hazard term or drop hazard_search.`);
239
+ }
240
+ if (filters.some((f) => f.words.length > 1)) {
241
+ parts.push('A text filter with several words matches only records that contain every word — try fewer words.');
242
+ }
243
+ if (filters.some((f) => f.key === 'manufacturer')) {
244
+ parts.push('Many recalls name the importer, retailer, or distributor rather than the manufacturer — try the same name in importer, retailer, or distributor.');
245
+ }
246
+ if (hasDateBound) {
247
+ parts.push(filters.length > 0 ? 'Widen or drop the date bounds.' : 'Widen the date bounds.');
248
+ }
249
+ parts.push(JURISDICTION);
250
+ return parts.join(' ');
251
+ }
17
252
  export const cpscSearchRecalls = tool('cpsc_search_recalls', {
18
253
  title: 'Search CPSC Recalls',
19
- description: 'Search consumer product recalls from the CPSC (Consumer Product Safety Commission) database. ' +
20
- "Covers toys, electronics, furniture, appliances, children's products, tools, and clothing — everything under CPSC jurisdiction. " +
21
- 'Does NOT cover food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), or pesticides (EPA). ' +
22
- 'All filter fields are optional substring matches that combine with AND. ' +
23
- 'Start with title_search when you have a product in hand — CPSC titles carry the brand, product, and hazard phrasing. ' +
24
- 'For hazard-type filtering ("fire", "choking", "burn"), use hazard_search, which matches hazard text, product names, and remedy instructions in one pass. ' +
25
- 'When manufacturer returns no results, try importer, retailer, or distributor: many recalls list one of those as the primary responsible org. ' +
26
- 'Page past limit with offset — total_found and has_more say where the window sits in the full result set. ' +
27
- 'Use cpsc_get_recall with a recall_number from results to retrieve the full record including complete description, all images, and incident reports.',
254
+ description: 'Search consumer product recalls from the CPSC (Consumer Product Safety Commission) — toys, electronics, furniture, appliances, children\'s products, tools, and clothing. Give at least one text filter or date bound; to browse the latest recalls without one, use cpsc_get_recent. Filters combine with AND, and a text filter matches records whose field contains every one of its words, in any order and case-insensitively. Start with title_search when you have a product in hand — CPSC titles carry the brand, product, and hazard phrasing. For hazard types ("fire", "choking", "burn"), use hazard_search, which matches hazard descriptions, product names, and remedy instructions. When manufacturer finds nothing, try importer, retailer, or distributor: many recalls name one of those as the responsible company. Page past limit with offset — total_found counts every match and has_more says whether more remain; a page returns fewer than limit recalls when it reaches the 64,000-byte response size budget. Pass a recall_number to cpsc_get_recall for the full record, including the complete description, all images, and incident reports. CPSC jurisdiction: consumer products only — food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), and firearms (ATF) are covered by other agencies.',
28
255
  annotations: { readOnlyHint: true, idempotentHint: true },
29
256
  /**
30
257
  * `Hazard` is the upstream CPSC query parameter, so a caller working from the
@@ -35,186 +262,131 @@ export const cpscSearchRecalls = tool('cpsc_search_recalls', {
35
262
  input: z.object({
36
263
  product_name: z
37
264
  .string()
265
+ .max(500)
38
266
  .optional()
39
- .describe('Product name to search for, e.g. "crib", "space heater", "bicycle". Substring match — partial names work.'),
267
+ .describe('Product name to search for, e.g. "crib", "space heater", "bicycle". Matches recalls whose product names contain every word, in any order — partial words work.'),
40
268
  manufacturer: z
41
269
  .string()
270
+ .max(500)
42
271
  .optional()
43
- .describe('Manufacturer name, e.g. "Samsung", "LEGO". Substring match against the Manufacturers array. ' +
44
- 'Note: many recalls list the importer or retailer as the primary org rather than the manufacturer — try importer or retailer if this returns no results.'),
272
+ .describe('Manufacturer name, e.g. "Samsung", "LEGO". Matches recalls whose manufacturer names contain every word, in any order. Many recalls name the importer, retailer, or distributor rather than the manufacturer — try those filters when this finds nothing.'),
45
273
  retailer: z
46
274
  .string()
275
+ .max(500)
47
276
  .optional()
48
- .describe('Retailer name, e.g. "Walmart", "Target", "Amazon". Substring match against the retailer narrative (which includes store name, dates sold, and price).'),
277
+ .describe('Retailer name, e.g. "Walmart", "Target", "Amazon". Matches recalls whose retailer text (store name, dates sold, and price) contains every word, in any order.'),
49
278
  importer: z
50
279
  .string()
280
+ .max(500)
51
281
  .optional()
52
- .describe('Importer company name. Use when searching for recalls by the company that brought the product into the US.'),
282
+ .describe('Importer company name. Matches recalls whose importer names contain every word, in any order. Use when searching for recalls by the company that brought the product into the US.'),
53
283
  distributor: z
54
284
  .string()
285
+ .max(500)
55
286
  .optional()
56
- .describe('Distributor company name, e.g. "Walmart", "Costco". Substring match against the Distributors array — a role distinct from retailer and importer, and populated on far fewer records.'),
287
+ .describe('Distributor company name, e.g. "Walmart", "Costco". Matches recalls whose distributor names contain every word, in any order — a role distinct from retailer and importer, and listed on far fewer recalls.'),
57
288
  title_search: z
58
289
  .string()
290
+ .max(500)
59
291
  .optional()
60
- .describe('Keyword search within the recall Title, e.g. "chandelier", "space heater", "inclined sleeper". Substring match. ' +
61
- 'CPSC titles name the brand, the product, and the hazard, which makes this the highest-signal single filter for most searches.'),
292
+ .describe('Keyword search within the recall title, e.g. "chandelier", "space heater", "Graco crib". Matches titles containing every word, in any order. CPSC titles name the brand, the product, and the hazard, which makes this the highest-signal single filter for most searches.'),
62
293
  description_search: z
63
294
  .string()
295
+ .max(500)
64
296
  .optional()
65
- .describe('Keyword search within the recall Description field only (does not search Title, Hazards, or remedy text). ' +
66
- 'Use for product details not captured in product_name — model numbers, colors, sale channels. ' +
67
- 'For hazard concepts, prefer hazard_search; for the recall headline, prefer title_search.'),
297
+ .describe('Keyword search within the recall description only — not the title, hazard text, or remedy instructions; matches descriptions containing every word, in any order. Use for product details not captured in product_name — model numbers, colors, sale channels. For hazard concepts, prefer hazard_search; for the recall headline, prefer title_search.'),
68
298
  remedy: z
69
299
  .string()
300
+ .max(500)
70
301
  .optional()
71
- .describe('Keyword search within the free-text remedy instructions, e.g. "repair", "refund", "firmware update". Substring match, applied upstream. ' +
72
- 'This searches the remedy narrative, not the structured remedy_options enum — "repair" matches records whose remedy_options list only "Refund" but whose instructions describe a free repair kit. ' +
73
- 'Combines with the other filters using AND; use hazard_search instead to match remedy text as one of several fields.'),
302
+ .describe('Keyword search within the free-text remedy instructions, e.g. "repair", "refund", "firmware update"; matches instructions containing every word, in any order. This searches the remedy text, not the remedy_options categories — "repair" matches records whose remedy_options list only "Refund" but whose instructions describe a free repair kit. Combines with the other filters using AND; use hazard_search instead to match remedy text as one of several fields.'),
74
303
  hazard_search: z
75
304
  .string()
305
+ .max(500)
76
306
  .optional()
77
- .describe('Hazard or safety-concept keyword, e.g. "fire", "choking", "burn", "laceration". Applied client-side after the upstream fetch. ' +
78
- 'Matches when the term appears in any of: hazard descriptions, product names, or remedy instructions (OR across the three, case-insensitive substring). ' +
79
- 'Use this rather than the upstream Hazard parameter, which CPSC recognizes but never matches.'),
307
+ .describe('Hazard or safety-concept keyword, e.g. "fire", "choking", "burn", "tip-over dresser". Matches when every word appears in the hazard descriptions, product names, or remedy instructions — each word in any of the three, in any order, case-insensitive. The filter to use for hazard types; combine it with another filter to narrow a broad hazard.'),
80
308
  date_start: z
81
309
  .union([z.literal(''), z.iso.date().describe('ISO 8601 date: "YYYY-MM-DD".')])
82
310
  .optional()
83
- .describe('Include only recalls on or after this date. ISO 8601 format: "YYYY-MM-DD". ' +
84
- 'Must be a real calendar date — "2026-02-31" and "2026-99-99" are rejected.'),
311
+ .describe('Include only recalls on or after this date. ISO 8601 format: "YYYY-MM-DD". Must be a real calendar date — "2026-02-31" and "2026-99-99" are rejected.'),
85
312
  date_end: z
86
313
  .union([z.literal(''), z.iso.date().describe('ISO 8601 date: "YYYY-MM-DD".')])
87
314
  .optional()
88
- .describe('Include only recalls on or before this date. ISO 8601 format: "YYYY-MM-DD". ' +
89
- 'Must be a real calendar date, and on or after date_start.'),
315
+ .describe('Include only recalls on or before this date. ISO 8601 format: "YYYY-MM-DD". Must be a real calendar date, and on or after date_start.'),
90
316
  updated_start: z
91
317
  .union([z.literal(''), z.iso.date().describe('ISO 8601 date: "YYYY-MM-DD".')])
92
318
  .optional()
93
- .describe('Include only recalls last published by CPSC on or after this date. ISO 8601 format: "YYYY-MM-DD". ' +
94
- 'A separate axis from date_start: a 2003 recall re-published in 2025 matches updated_start "2025-01-01". ' +
95
- 'Use to answer "what has CPSC updated recently". Must be a real calendar date.'),
319
+ .describe('Include only recalls last published by CPSC on or after this date. ISO 8601 format: "YYYY-MM-DD". A separate axis from date_start: a 2003 recall re-published in 2025 matches updated_start "2025-01-01". Use to answer "what has CPSC updated recently". Must be a real calendar date.'),
96
320
  updated_end: z
97
321
  .union([z.literal(''), z.iso.date().describe('ISO 8601 date: "YYYY-MM-DD".')])
98
322
  .optional()
99
- .describe('Include only recalls last published by CPSC on or before this date. ISO 8601 format: "YYYY-MM-DD". ' +
100
- 'Must be a real calendar date, and on or after updated_start.'),
323
+ .describe('Include only recalls last published by CPSC on or before this date. ISO 8601 format: "YYYY-MM-DD". Must be a real calendar date, and on or after updated_start.'),
101
324
  limit: z
102
325
  .number()
103
326
  .int()
104
327
  .min(1)
105
328
  .max(200)
106
329
  .default(20)
107
- .describe('Maximum number of results to return (applied client-side — the API returns all matches). Defaults to 20.'),
330
+ .describe('Maximum number of recalls to return on this page. Defaults to 20. A page returns fewer when it reaches the 64,000-byte response size budget; has_more then says more remain.'),
108
331
  offset: z
109
332
  .number()
110
333
  .int()
111
334
  .min(0)
112
335
  .default(0)
113
- .describe('Skip this many matching records before returning results. Combine with limit to page through total_found — e.g. limit 20 with offset 0, 20, 40. ' +
114
- 'An offset at or past total_found returns an empty result set rather than an error.'),
336
+ .describe('Skip this many matching records before returning results. To page through total_found, raise offset by the number of recalls the previous page returned — limit, unless that page reached the response size budget. An offset at or past total_found returns an empty result set rather than an error.'),
115
337
  }),
116
338
  output: z.object({
117
- recalls: z
118
- .array(z
119
- .object({
120
- recall_number: z
121
- .string()
122
- .describe('Recall identifier (5-digit numeric for 2002+ records, e.g. "25043"; 6-char with letter suffix for 1998–2001 records, e.g. "99003a"). Pass to cpsc_get_recall for full detail.'),
123
- recall_date: z.string().describe('Date the recall was issued, ISO 8601.'),
124
- title: z.string().describe('Official recall title.'),
125
- hazards: z
126
- .array(z.string().describe('Hazard description — what is dangerous.'))
127
- .describe('Hazard descriptions — what is dangerous about this product.'),
128
- remedy_options: z
129
- .array(z.string().describe('Remedy type.'))
130
- .describe('Remedy types: Refund, Repair, Replace, New Instructions, Dispose, Label, No Remedy Available, Inspect. ' +
131
- 'Multiple may apply. Often empty — CPSC classified the remedy on fewer than half its records; ' +
132
- 'read remedy_summary when this is empty, and cpsc_url when that is empty too.'),
133
- remedy_summary: z
134
- .string()
135
- .describe('Full remedy instructions — what the consumer should do and how to claim.'),
136
- products: z
137
- .array(z
138
- .object({
139
- name: z.string().describe('Product name.'),
140
- units_recalled: z
141
- .string()
142
- .describe('Estimated number of units recalled, e.g. "About 2,500".'),
143
- })
144
- .describe('A product covered by this recall.'))
145
- .describe('Products covered by this recall. A recall may cover multiple products.'),
146
- upcs: z
147
- .array(z.string().describe('UPC code.'))
148
- .describe('UPC codes for this recall (sparse — ~4% of records have UPCs). ' +
149
- 'UPCs are stored at the recall level in the API, not per-product; ' +
150
- 'when a recall covers multiple products, all UPCs apply to the recall as a whole.'),
151
- manufacturers: z
152
- .array(z.string().describe('Manufacturer name.'))
153
- .describe('Manufacturer names. Often empty — importer or retailer may be listed instead.'),
154
- importers: z
155
- .array(z.string().describe('Importer name.'))
156
- .describe('Importer company names.'),
157
- retailers: z
158
- .array(z.string().describe('Retailer name and sale details.'))
159
- .describe('Retailer names and sale details (narrative text including stores, dates, price range).'),
160
- cpsc_url: z.string().describe('Official CPSC recall page URL for human verification.'),
161
- images: z
162
- .array(z
163
- .object({
164
- url: z.string().describe('Image URL.'),
165
- caption: z.string().describe('Image caption.'),
166
- })
167
- .describe('An image from the recall notice.'))
168
- .describe('Product images from the recall notice.'),
169
- data_quality_notes: z
170
- .array(z.string().describe('One gap found in the upstream record.'))
171
- .describe('Gaps this server observed in the upstream CPSC record — absent hazard text, absent product entries. ' +
172
- 'Derived from which fields CPSC left empty, not from any judgement about the recall itself. Empty when nothing is missing.'),
173
- })
174
- .describe('A CPSC recall record.'))
175
- .describe('Matching recalls, ordered newest-first.'),
339
+ recalls: z.array(recallSchema).describe('Matching recalls, ordered newest-first.'),
176
340
  total_found: z
177
341
  .number()
178
- .describe('Total matching records, counted after hazard_search is applied and before offset and limit narrow the window.'),
342
+ .describe('Total matching records, counted after every filter is applied and before offset, limit, and the response size budget narrow the window.'),
179
343
  truncated: z
180
344
  .boolean()
181
- .describe('True when total_found exceeds the limit. Independent of offset.'),
345
+ .describe('True when matching records remain past this page — always equal to has_more.'),
182
346
  offset: z.number().describe('Number of matching records skipped before this window.'),
183
347
  has_more: z
184
348
  .boolean()
185
349
  .describe('True when records remain past this window — call again with offset raised by the number of recalls returned.'),
186
350
  cpsc_jurisdiction: z
187
351
  .string()
188
- .describe('CPSC covers consumer products — toys, electronics, furniture, appliances, tools, clothing. ' +
189
- 'Does NOT cover: food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), firearms (ATF).'),
352
+ .describe('Which products CPSC covers and which agencies cover the rest — food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), firearms (ATF).'),
190
353
  source_note: z
191
354
  .string()
192
355
  .describe('Provenance caveat: recall fields are relayed from CPSC unedited and unverified; check cpsc_url before a consumer-facing decision.'),
193
356
  }),
357
+ enrichment: {
358
+ effectiveQuery: z
359
+ .string()
360
+ .describe('The search criteria as applied, joined with AND: each non-blank text filter trimmed, the hazard alias resolved to hazard_search, and blank date bounds dropped.'),
361
+ notice: z
362
+ .string()
363
+ .optional()
364
+ .describe('Present when the page needs explaining: zero matches (with which criterion to relax), an offset past the last match, or a page cut short by the response size budget (with the offset to continue from).'),
365
+ },
194
366
  errors: [
367
+ {
368
+ reason: 'missing_criteria',
369
+ code: JsonRpcErrorCode.ValidationError,
370
+ when: 'No search criterion was given: every text filter was omitted or blank and no date bound was set',
371
+ recovery: 'Set at least one criterion — title_search, product_name, manufacturer, importer, retailer, distributor, description_search, remedy, hazard_search, or a date bound (date_start, date_end, updated_start, updated_end). To browse the latest recalls without a filter, call cpsc_get_recent instead.',
372
+ },
195
373
  {
196
374
  reason: 'invalid_date_range',
197
375
  code: JsonRpcErrorCode.ValidationError,
198
376
  when: 'date_start is later than date_end, or updated_start is later than updated_end, so the range can never match',
199
377
  recovery: 'Swap the two dates, or drop one of them. date_start and date_end bound the recall issue date; updated_start and updated_end bound the date CPSC last published the record.',
200
378
  },
201
- {
202
- reason: 'no_results',
203
- code: JsonRpcErrorCode.NotFound,
204
- when: 'No recalls matched the search filters, including hazard_search',
205
- recovery: 'Broaden the search — try a shorter product name, fewer filters, or remove the date range. If hazard_search was set, drop it: it narrows the upstream results further, client-side. Check CPSC jurisdiction: food, vehicle, and drug recalls are not in this database.',
206
- },
207
379
  {
208
380
  reason: 'upstream_error',
209
381
  code: JsonRpcErrorCode.ServiceUnavailable,
210
- when: 'The saferproducts.gov API returned a transient error or timed out',
211
- recovery: 'The CPSC API is occasionally unavailable. Retry in a few seconds.',
382
+ when: 'The CPSC recall service (saferproducts.gov) was unavailable, timed out, or sent an unreadable response',
383
+ recovery: 'The CPSC recall service is occasionally unavailable. Retry in a few seconds.',
212
384
  retryable: true,
213
385
  },
214
386
  {
215
387
  reason: 'upstream_rejected',
216
388
  code: JsonRpcErrorCode.ServiceUnavailable,
217
- when: 'The saferproducts.gov API answered with an error row instead of recall records, which the same request will always produce',
389
+ when: 'CPSC rejected the request instead of returning recalls, and it rejects the same request every time',
218
390
  recovery: 'Do not retry this request unchanged — it fails deterministically. Read the message for what CPSC rejected, then change a filter value and call again.',
219
391
  retryable: false,
220
392
  },
@@ -237,29 +409,43 @@ export const cpscSearchRecalls = tool('cpsc_search_recalls', {
237
409
  limit: input.limit,
238
410
  offset: input.offset,
239
411
  });
412
+ /** A blank or whitespace-only text filter is omitted, as the date fields already treat "". */
413
+ const filters = TEXT_FILTERS.flatMap((filter) => {
414
+ const value = input[filter.key]?.trim().split(/\s+/).join(' ');
415
+ return value ? [{ ...filter, value, words: fold(value).split(' ') }] : [];
416
+ });
417
+ const dateBounds = DATE_BOUNDS.flatMap((key) => {
418
+ const value = input[key];
419
+ return value ? [{ key, value }] : [];
420
+ });
421
+ if (filters.length === 0 && dateBounds.length === 0) {
422
+ throw ctx.fail('missing_criteria', 'cpsc_search_recalls needs at least one criterion — a non-blank text filter or a date bound. limit and offset alone do not narrow a search.', { ...ctx.recoveryFor('missing_criteria') });
423
+ }
240
424
  if (input.date_start && input.date_end && input.date_start > input.date_end) {
241
425
  throw ctx.fail('invalid_date_range', `date_start "${input.date_start}" is later than date_end "${input.date_end}".`, { ...ctx.recoveryFor('invalid_date_range') });
242
426
  }
243
427
  if (input.updated_start && input.updated_end && input.updated_start > input.updated_end) {
244
428
  throw ctx.fail('invalid_date_range', `updated_start "${input.updated_start}" is later than updated_end "${input.updated_end}".`, { ...ctx.recoveryFor('invalid_date_range') });
245
429
  }
430
+ const params = {
431
+ ...(input.date_start && { RecallDateStart: input.date_start }),
432
+ ...(input.date_end && { RecallDateEnd: input.date_end }),
433
+ ...(input.updated_start && { LastPublishDateStart: input.updated_start }),
434
+ ...(input.updated_end && { LastPublishDateEnd: input.updated_end }),
435
+ };
436
+ for (const filter of filters) {
437
+ if (!filter.param)
438
+ continue;
439
+ const word = upstreamWord(filter.value);
440
+ if (word)
441
+ params[filter.param] = word;
442
+ }
443
+ if (Object.keys(params).length === 0)
444
+ params.RecallDateStart = RECALL_DATE_FLOOR;
246
445
  const svc = getCpscRecallService();
247
446
  let raw;
248
447
  try {
249
- raw = await svc.search({
250
- ...(input.product_name && { ProductName: input.product_name }),
251
- ...(input.manufacturer && { Manufacturer: input.manufacturer }),
252
- ...(input.retailer && { Retailer: input.retailer }),
253
- ...(input.importer && { Importer: input.importer }),
254
- ...(input.distributor && { Distributor: input.distributor }),
255
- ...(input.title_search && { RecallTitle: input.title_search }),
256
- ...(input.description_search && { RecallDescription: input.description_search }),
257
- ...(input.remedy && { Remedy: input.remedy }),
258
- ...(input.date_start && { RecallDateStart: input.date_start }),
259
- ...(input.date_end && { RecallDateEnd: input.date_end }),
260
- ...(input.updated_start && { LastPublishDateStart: input.updated_start }),
261
- ...(input.updated_end && { LastPublishDateEnd: input.updated_end }),
262
- }, ctx);
448
+ raw = await svc.search(params, ctx);
263
449
  }
264
450
  catch (err) {
265
451
  const detail = err instanceof Error ? err.message : String(err);
@@ -269,128 +455,64 @@ export const cpscSearchRecalls = tool('cpsc_search_recalls', {
269
455
  throw ctx.fail('upstream_error', `CPSC API request failed: ${detail}`, { ...ctx.recoveryFor('upstream_error') }, { cause: err });
270
456
  }
271
457
  /**
272
- * Normalize the full upstream array before anything narrows it: hazard_search filters
273
- * on normalized fields, and total_found must count post-filter but pre-window, so the
274
- * offset/limit slice is the last step.
458
+ * Word checks run over the full upstream array: total_found counts post-filter but
459
+ * pre-window, so the offset/limit slice comes last. hazard_search runs after the others
460
+ * so a zero-match notice can say how many records it removed.
275
461
  */
276
- const normalized = raw.map((r) => {
277
- const hazards = r.Hazards.map((h) => h.Name).filter(Boolean);
278
- const products = r.Products.map((p) => ({
279
- name: p.Name,
280
- units_recalled: p.NumberOfUnits ?? '',
281
- }));
282
- const data_quality_notes = [];
283
- if (hazards.length === 0) {
284
- data_quality_notes.push('CPSC listed no hazard description for this recall.');
285
- }
286
- if (products.length === 0) {
287
- data_quality_notes.push('CPSC listed no product entries for this recall.');
288
- }
289
- return {
290
- recall_number: r.RecallNumber,
291
- recall_date: r.RecallDate.slice(0, 10),
292
- title: r.Title,
293
- hazards,
294
- remedy_options: r.RemedyOptions.map((o) => o.Option).filter(Boolean),
295
- remedy_summary: r.Remedies.map((rem) => rem.Name)
296
- .filter(Boolean)
297
- .join(' '),
298
- products,
299
- upcs: r.ProductUPCs.map((u) => u.UPC).filter(Boolean),
300
- manufacturers: r.Manufacturers.map((m) => m.Name).filter(Boolean),
301
- importers: r.Importers.map((i) => i.Name).filter(Boolean),
302
- retailers: r.Retailers.map((ret) => ret.Name).filter(Boolean),
303
- cpsc_url: r.URL,
304
- images: r.Images.map((img) => ({ url: img.URL, caption: img.Caption })),
305
- data_quality_notes,
306
- };
307
- });
308
- const needle = input.hazard_search?.trim().toLowerCase();
309
- const filtered = needle
310
- ? normalized.filter((r) => r.hazards.some((h) => h.toLowerCase().includes(needle)) ||
311
- r.products.some((p) => p.name.toLowerCase().includes(needle)) ||
312
- r.remedy_summary.toLowerCase().includes(needle))
313
- : normalized;
314
- if (filtered.length === 0) {
315
- throw ctx.fail('no_results', 'No recalls matched the search filters.', {
316
- ...ctx.recoveryFor('no_results'),
317
- });
318
- }
319
- const total_found = filtered.length;
320
- const truncated = total_found > input.limit;
321
- // An offset at or past total_found is a valid empty page, not a no_results error.
322
- const recalls = filtered.slice(input.offset, input.offset + input.limit);
323
- const has_more = input.offset + recalls.length < total_found;
324
- ctx.log.info('Search complete', {
462
+ const hazardFilter = filters.find((f) => f.key === 'hazard_search');
463
+ const otherFilters = filters.filter((f) => f !== hazardFilter);
464
+ const matchedBeforeHazard = raw.filter((r) => otherFilters.every((f) => matchesEveryWord(r, f)));
465
+ const matched = hazardFilter
466
+ ? matchedBeforeHazard.filter((r) => matchesEveryWord(r, hazardFilter))
467
+ : matchedBeforeHazard;
468
+ const total_found = matched.length;
469
+ const window = matched.slice(input.offset, input.offset + input.limit).map(toSearchRecall);
470
+ const effectiveQuery = [
471
+ ...filters.map((f) => `${f.key}=${JSON.stringify(f.value)}`),
472
+ ...dateBounds.map((d) => `${d.key}=${d.value}`),
473
+ ].join(' AND ');
474
+ const page = {
325
475
  total_found,
326
- returned: recalls.length,
327
476
  offset: input.offset,
328
- truncated,
329
- has_more,
477
+ cpsc_jurisdiction: JURISDICTION,
478
+ source_note: SOURCE_NOTE,
479
+ };
480
+ /**
481
+ * The overhead reserves the page's worst case: the longest footer (truncated, with a
482
+ * next-page offset for a full limit of records) and a budget notice at the same counts.
483
+ */
484
+ const overhead = pageOverheadBytes({ ...page, recalls: [], truncated: false, has_more: false }, renderFooter({ ...page, truncated: true, has_more: true }, input.limit), {
485
+ effectiveQuery,
486
+ notice: budgetCutNotice(input.limit, input.limit, input.offset + input.limit),
330
487
  });
331
- return {
332
- recalls,
488
+ const returned = countWithinBudget(window, renderRecallBlock, overhead);
489
+ const recalls = window.slice(0, returned);
490
+ const has_more = input.offset + returned < total_found;
491
+ /** One notice slot, last-wins: the three page states are mutually exclusive. */
492
+ const notice = total_found === 0
493
+ ? zeroMatchNotice(filters, matchedBeforeHazard.length, dateBounds.length > 0)
494
+ : input.offset >= total_found
495
+ ? `Offset ${input.offset} is past the last of the ${total_found} matching recall${total_found === 1 ? '' : 's'}, so this page is empty. Use an offset below ${total_found}.`
496
+ : returned < window.length
497
+ ? budgetCutNotice(returned, input.limit, input.offset + returned)
498
+ : undefined;
499
+ ctx.enrich.echo(effectiveQuery);
500
+ if (notice)
501
+ ctx.enrich.notice(notice);
502
+ ctx.log.info('Search complete', {
333
503
  total_found,
334
- truncated,
504
+ returned,
335
505
  offset: input.offset,
336
506
  has_more,
337
- cpsc_jurisdiction: JURISDICTION,
338
- source_note: SOURCE_NOTE,
339
- };
507
+ });
508
+ return { ...page, recalls, truncated: has_more, has_more };
340
509
  },
341
510
  format(result) {
342
- const lines = [];
343
- for (const r of result.recalls) {
344
- lines.push(`## [${r.recall_number}] — ${r.title} (${r.recall_date})`);
345
- lines.push('CPSC source fields:');
346
- const hazardText = r.hazards.length > 0 ? r.hazards.join('; ') : 'Not specified';
347
- lines.push(`**Hazard:** ${hazardText}`);
348
- const remedyTypes = r.remedy_options.length > 0 ? r.remedy_options.join(', ') : 'Not specified';
349
- const remedyText = r.remedy_summary || 'See CPSC recall page.';
350
- lines.push(`**Remedy:** ${remedyTypes} — ${remedyText}`);
351
- const productNames = r.products
352
- .map((p) => `${p.name} (${p.units_recalled || 'units not specified'})`)
353
- .join('; ');
354
- lines.push(`**Products:** ${productNames || 'Not specified'}`);
355
- if (r.upcs.length > 0) {
356
- lines.push(`**UPCs:** ${r.upcs.join(', ')}`);
357
- }
358
- const soldBy = r.retailers.length > 0 ? r.retailers.join('; ') : 'Not specified';
359
- lines.push(`**Sold by:** ${soldBy}`);
360
- /**
361
- * Manufacturer and importer are distinct roles and CPSC org names contain commas,
362
- * so each role gets its own line and entries are separated with '; '.
363
- */
364
- if (r.manufacturers.length > 0) {
365
- lines.push(`**Manufacturer:** ${r.manufacturers.join('; ')}`);
366
- }
367
- if (r.importers.length > 0) {
368
- lines.push(`**Importer:** ${r.importers.join('; ')}`);
369
- }
370
- if (r.manufacturers.length === 0 && r.importers.length === 0) {
371
- lines.push('**Manufacturer/Importer:** Not specified');
372
- }
373
- if (r.images.length > 0) {
374
- const imgList = r.images.map((img) => `${img.caption}: ${img.url}`).join('; ');
375
- lines.push(`**Images (${r.images.length}):** ${imgList}`);
376
- }
377
- else {
378
- lines.push(`**Images:** None`);
379
- }
380
- lines.push(`[View recall](${r.cpsc_url})`);
381
- if (r.data_quality_notes.length > 0) {
382
- lines.push(`**Data quality (server-assessed):** ${r.data_quality_notes.join(' ')}`);
383
- }
384
- lines.push('---');
385
- }
386
- const truncNote = result.truncated ? ' (truncated by limit)' : '';
387
- const paging = result.has_more
388
- ? ` More available — repeat with offset ${result.offset + result.recalls.length}.`
389
- : '';
390
- lines.push(`Showing ${result.recalls.length} of ${result.total_found} recalls${truncNote}, starting at offset ${result.offset}.${paging}`);
391
- lines.push(`Source: ${result.source_note}`);
392
- lines.push(`CPSC covers: ${result.cpsc_jurisdiction}`);
393
- return [{ type: 'text', text: lines.join('\n') }];
511
+ const text = [
512
+ ...result.recalls.map(renderRecallBlock),
513
+ renderFooter(result, result.recalls.length),
514
+ ].join('\n');
515
+ return [{ type: 'text', text }];
394
516
  },
395
517
  });
396
518
  //# sourceMappingURL=cpsc-search-recalls.tool.js.map