@cyanheads/cpsc-recalls-mcp-server 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** cpsc-recalls-mcp-server
4
- **Version:** 0.2.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.16`
4
+ **Version:** 0.3.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1 (protocol revisions 2026-07-28 and 2025-*)
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (protocol revisions 2026-07-28 and 2025-*)
8
8
  **Zod:** ^4.6.7
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -50,7 +50,9 @@ The three registered tools live in `src/mcp-server/tools/definitions/`:
50
50
  - `cpsc-get-recall.tool.ts` expands a recall number into the full record and distinguishes quoted CPSC narrative from server guidance.
51
51
  - `cpsc-get-recent.tool.ts` browses a date window with the same pagination and response-budget contract as search.
52
52
 
53
- All three declare typed error contracts and use `getCpscRecallService()`. The service normalizes CPSC text once before returning records. There are no resources, prompts, or server-specific environment variables.
53
+ All three declare typed error contracts and use `getCpscRecallService()`. The service normalizes CPSC text once before returning records. There are no prompts or server-specific environment variables, and no resource definitions: the only resource is the published skill `skill://cpsc-recalls-guide/SKILL.md`, which the framework serves from root `skills/` (`createApp({ skills: 'skills' })` in `src/index.ts`).
54
+
55
+ `skills/cpsc-recalls-guide/SKILL.md` carries what the tool descriptions don't: search-formulation gates (one empty search is not "not recalled"), recall-number and identifier quirks, and the jurisdiction edges. Edit it with the `add-skill` skill, and re-check its gates against live tool behavior with `field-test`.
54
56
 
55
57
  ### Server identity and instructions
56
58
 
@@ -174,7 +176,7 @@ src/
174
176
 
175
177
  ## Naming
176
178
 
177
- **CPSC prose formatting:** retain `src/mcp-server/tools/markdown-escape.ts` for plain CPSC text: it preserves GFM autolinks and escapes closing heading `#` sequences, which the framework's CommonMark inline escaper does not handle in those contexts. URL fields use the framework's `markdownLink` / `markdownImage`; enrichment byte reservations use `escapeMarkdownInline` to match the framework trailer.
179
+ **CPSC prose formatting:** retain `src/mcp-server/tools/markdown-escape.ts` for plain CPSC text: it leaves an address GFM autolinks unescaped, where the framework's CommonMark inline escaper would put a backslash inside the link. URL fields use the framework's `markdownLink` / `markdownImage`. The enrichment byte reservation in `response-budget.ts` models the framework trailer's rendering — code spans and fenced blocks sized past each value's backtick runs, `> `-quoted notice lines — and needs re-checking whenever the framework changes that trailer.
178
180
 
179
181
  | What | Convention | Example |
180
182
  |:-----|:-----------|:--------|
@@ -187,9 +189,18 @@ src/
187
189
 
188
190
  ## Skills
189
191
 
190
- Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
192
+ Two skill trees live at the project root, written for different readers:
193
+
194
+ | Tree | For | Written by |
195
+ |:-----|:----|:-----------|
196
+ | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
197
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. This server publishes one: `cpsc-recalls-guide` |
198
+
199
+ Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.
200
+
201
+ A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.
191
202
 
192
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
203
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
193
204
 
194
205
  Available skills:
195
206
 
@@ -203,6 +214,7 @@ Available skills:
203
214
  | `add-prompt` | Scaffold a new prompt definition |
204
215
  | `add-service` | Scaffold a new service integration |
205
216
  | `add-test` | Scaffold test file for a tool, resource, or service |
217
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
206
218
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
207
219
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
208
220
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
@@ -248,7 +260,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
248
260
  | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update <name>`, then `bun dedupe` |
249
261
  | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |
250
262
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
251
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
263
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, the published `skills/` tree (run by devcheck) |
252
264
  | `bun run list-skills` | Print the skill registry |
253
265
  | `bun run tree` | Generate directory structure doc |
254
266
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -266,7 +278,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
266
278
 
267
279
  ## Bundling
268
280
 
269
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
281
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/` at a package root, and any `SKILL.md` — a runtime directory deeper in a package, such as the MCP SDK's `dist/ext/skills/`, ships); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp` (`config.gypi`, Makefiles, `obj.target/`, `.deps/`, object files and debug symbols), which records the packing machine's paths — compiled `*.node` addons stay; and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, `CLAUDE.md`, `AGENTS.md`, and config bases (`biome.json`, `vitest.config.base.mjs`, `config/tsconfig.base.json`), and `better-sqlite3`'s `deps/` and `src/` C sources. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
270
282
 
271
283
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`.
272
284
 
@@ -333,7 +345,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
333
345
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
334
346
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
335
347
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
348
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name (lowercase `a-z`, `0-9`, `-`, `.` only); `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y <full package name>`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
336
349
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
337
350
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
338
351
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
352
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
353
+ - [ ] If the server publishes a skill in root `skills/`: `lint:packaging` passes, and `field-test` verified every gate and identifier claim in it against live tool behavior
339
354
  - [ ] `bun run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** cpsc-recalls-mcp-server
4
- **Version:** 0.2.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.16`
4
+ **Version:** 0.3.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1 (protocol revisions 2026-07-28 and 2025-*)
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (protocol revisions 2026-07-28 and 2025-*)
8
8
  **Zod:** ^4.6.7
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -50,7 +50,9 @@ The three registered tools live in `src/mcp-server/tools/definitions/`:
50
50
  - `cpsc-get-recall.tool.ts` expands a recall number into the full record and distinguishes quoted CPSC narrative from server guidance.
51
51
  - `cpsc-get-recent.tool.ts` browses a date window with the same pagination and response-budget contract as search.
52
52
 
53
- All three declare typed error contracts and use `getCpscRecallService()`. The service normalizes CPSC text once before returning records. There are no resources, prompts, or server-specific environment variables.
53
+ All three declare typed error contracts and use `getCpscRecallService()`. The service normalizes CPSC text once before returning records. There are no prompts or server-specific environment variables, and no resource definitions: the only resource is the published skill `skill://cpsc-recalls-guide/SKILL.md`, which the framework serves from root `skills/` (`createApp({ skills: 'skills' })` in `src/index.ts`).
54
+
55
+ `skills/cpsc-recalls-guide/SKILL.md` carries what the tool descriptions don't: search-formulation gates (one empty search is not "not recalled"), recall-number and identifier quirks, and the jurisdiction edges. Edit it with the `add-skill` skill, and re-check its gates against live tool behavior with `field-test`.
54
56
 
55
57
  ### Server identity and instructions
56
58
 
@@ -174,7 +176,7 @@ src/
174
176
 
175
177
  ## Naming
176
178
 
177
- **CPSC prose formatting:** retain `src/mcp-server/tools/markdown-escape.ts` for plain CPSC text: it preserves GFM autolinks and escapes closing heading `#` sequences, which the framework's CommonMark inline escaper does not handle in those contexts. URL fields use the framework's `markdownLink` / `markdownImage`; enrichment byte reservations use `escapeMarkdownInline` to match the framework trailer.
179
+ **CPSC prose formatting:** retain `src/mcp-server/tools/markdown-escape.ts` for plain CPSC text: it leaves an address GFM autolinks unescaped, where the framework's CommonMark inline escaper would put a backslash inside the link. URL fields use the framework's `markdownLink` / `markdownImage`. The enrichment byte reservation in `response-budget.ts` models the framework trailer's rendering — code spans and fenced blocks sized past each value's backtick runs, `> `-quoted notice lines — and needs re-checking whenever the framework changes that trailer.
178
180
 
179
181
  | What | Convention | Example |
180
182
  |:-----|:-----------|:--------|
@@ -187,9 +189,18 @@ src/
187
189
 
188
190
  ## Skills
189
191
 
190
- Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
192
+ Two skill trees live at the project root, written for different readers:
193
+
194
+ | Tree | For | Written by |
195
+ |:-----|:----|:-----------|
196
+ | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
197
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. This server publishes one: `cpsc-recalls-guide` |
198
+
199
+ Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.
200
+
201
+ A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.
191
202
 
192
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
203
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
193
204
 
194
205
  Available skills:
195
206
 
@@ -203,6 +214,7 @@ Available skills:
203
214
  | `add-prompt` | Scaffold a new prompt definition |
204
215
  | `add-service` | Scaffold a new service integration |
205
216
  | `add-test` | Scaffold test file for a tool, resource, or service |
217
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
206
218
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
207
219
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
208
220
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
@@ -248,7 +260,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
248
260
  | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update <name>`, then `bun dedupe` |
249
261
  | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |
250
262
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
251
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
263
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, the published `skills/` tree (run by devcheck) |
252
264
  | `bun run list-skills` | Print the skill registry |
253
265
  | `bun run tree` | Generate directory structure doc |
254
266
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -266,7 +278,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
266
278
 
267
279
  ## Bundling
268
280
 
269
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
281
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/` at a package root, and any `SKILL.md` — a runtime directory deeper in a package, such as the MCP SDK's `dist/ext/skills/`, ships); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp` (`config.gypi`, Makefiles, `obj.target/`, `.deps/`, object files and debug symbols), which records the packing machine's paths — compiled `*.node` addons stay; and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, `CLAUDE.md`, `AGENTS.md`, and config bases (`biome.json`, `vitest.config.base.mjs`, `config/tsconfig.base.json`), and `better-sqlite3`'s `deps/` and `src/` C sources. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
270
282
 
271
283
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`.
272
284
 
@@ -333,7 +345,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
333
345
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
334
346
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
335
347
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
348
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name (lowercase `a-z`, `0-9`, `-`, `.` only); `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y <full package name>`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
336
349
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
337
350
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
338
351
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
352
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
353
+ - [ ] If the server publishes a skill in root `skills/`: `lint:packaging` passes, and `field-test` verified every gate and identifier claim in it against live tool behavior
339
354
  - [ ] `bun run devcheck` passes
package/Dockerfile CHANGED
@@ -29,6 +29,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
29
29
  # Copy the rest of the source code
30
30
  COPY . .
31
31
 
32
+ # The skills a server publishes (`createApp({ skills: 'skills' })`) are read at
33
+ # runtime from the application root. Created when the project has none, so the
34
+ # production stage's copy below holds with or without one.
35
+ RUN mkdir -p skills
36
+
32
37
  # Build the application
33
38
  RUN bun run build
34
39
 
@@ -40,8 +45,8 @@ RUN bun run build
40
45
  # here can run JavaScript — bunfig.toml's security scanner runs as a Bun
41
46
  # program, and so do the OTel and musl-prune scripts — so the stage runs on
42
47
  # $BUILDPLATFORM and cross-installs with `--os`/`--cpu`, which pick each
43
- # platform-specific optional dependency (native bindings) for the target. Only
44
- # `node_modules` leaves this stage.
48
+ # platform-specific optional dependency (native bindings such as DuckDB's) for
49
+ # the target. Only `node_modules` leaves this stage.
45
50
  #
46
51
  # A clean image rather than `FROM build`: the build stage's node_modules holds
47
52
  # devDependencies.
@@ -94,10 +99,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
94
99
  fi
95
100
 
96
101
  # `--os`/`--cpu` have no libc counterpart, so a native dependency published in
97
- # glibc and musl variants installs both. The runtime image is Debian (glibc) and
98
- # never loads the musl copy; the script deletes every package whose own `libc`
99
- # admits only musl. It follows every install, since a later `bun install`
100
- # restores what it removes.
102
+ # glibc and musl variants (DuckDB's bindings, for one) installs both. The
103
+ # runtime image is Debian (glibc) and never loads the musl copy; the script
104
+ # deletes every package whose own `libc` admits only musl. It follows every
105
+ # install, since a later `bun install` restores what it removes. A server on a
106
+ # musl (Alpine) runtime image drops these two lines.
101
107
  COPY scripts/prune-musl-packages.ts ./scripts/
102
108
  RUN bun scripts/prune-musl-packages.ts
103
109
 
@@ -135,6 +141,7 @@ COPY --from=deps /usr/src/app/node_modules ./node_modules
135
141
 
136
142
  # Copy the compiled application code from the build stage
137
143
  COPY --from=build /usr/src/app/dist ./dist
144
+ COPY --from=build /usr/src/app/skills ./skills
138
145
 
139
146
  # The 'oven/bun' image already provides a non-root user named 'bun'.
140
147
  # We will use this existing user for enhanced security.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  <div align="center">
2
2
  <h1>@cyanheads/cpsc-recalls-mcp-server</h1>
3
3
  <p><b>Search and retrieve US consumer product recalls from the CPSC (Consumer Product Safety Commission) via MCP. STDIO or Streamable HTTP.</b>
4
- <div>3 Tools</div>
4
+ <div>3 Tools • 1 Skill</div>
5
5
  </p>
6
6
  </div>
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.2.3-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/cpsc-recalls-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.3.1-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/cpsc-recalls-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/cpsc-recalls-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.4+-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.3.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/cpsc-recalls-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.4.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/cpsc-recalls-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/cpsc-recalls-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.4+-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -39,6 +39,12 @@ Consumer product recalls from the CPSC [saferproducts.gov](https://www.saferprod
39
39
  | `cpsc_get_recall` | Full detail for a single recall by recall number — hazards, remedy, products, injuries, images, and the official CPSC page |
40
40
  | `cpsc_get_recent` | Fetch the most recent recalls ordered newest-first, scoped to a configurable date window |
41
41
 
42
+ ### Skills
43
+
44
+ | Skill | Description |
45
+ |:---|:---|
46
+ | `cpsc-recalls-guide` | Find and confirm US consumer product recalls with the `cpsc_*` tools — search formulation, recall-number quirks, and when an empty result proves nothing |
47
+
42
48
  CPSC jurisdiction is consumer products only — food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), and firearms (ATF) are not in this database; every response carries a jurisdiction note.
43
49
 
44
50
  ## Capability reference
@@ -64,6 +70,13 @@ CPSC jurisdiction is consumer products only — food/drugs (FDA), motor vehicles
64
70
  - `days` look-back window (1–365, default 30) anchored to today; `limit` (1–100, default 20) and `offset` page through `total_found`, since narrowing `days` drops the oldest recalls rather than paging. Older recalls are reachable through `cpsc_search_recalls` date bounds
65
71
  - Returns a lightweight record per recall — number, date, title, hazards, remedy types, product names, `cpsc_url`, `data_quality_notes` — for `cpsc_get_recall` to expand; `has_more` is the paging signal, and an empty window, an offset past the end, or a budget-cut page carries a notice. Failures: `upstream_rejected` (not retryable), `upstream_error` (retryable)
66
72
 
73
+ ---
74
+
75
+ ### `cpsc-recalls-guide` <sub>skill</sub>
76
+
77
+ - Helps an agent check whether a specific product, model, serial, or UPC was recalled, sweep a brand's or retailer's recall history, monitor new or republished recalls for a hazard, and decide whether a product is CPSC's at all
78
+ - Served over MCP as the resource `skill://cpsc-recalls-guide/SKILL.md` under the template `skill://{+path}`; also ships in `skills/` for plugin installs and the `.mcpb` bundle
79
+
67
80
  ## Features
68
81
 
69
82
  Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): stdio and Streamable HTTP transports, pluggable auth (`none` / `jwt` / `oauth`), swappable storage (`in-memory`, `filesystem`, `Supabase`, `Cloudflare KV/R2/D1`), structured logging with optional OpenTelemetry tracing.
@@ -85,133 +98,32 @@ Agent-friendly output:
85
98
 
86
99
  ## Getting started
87
100
 
88
- ### Public Hosted Instance
89
-
90
- A public instance is available at `https://cpsc-recalls.caseyjhand.com/mcp` — no installation required. It serves Streamable HTTP; each client takes its own configuration shape.
91
-
92
- For Claude Code:
101
+ Connect to the hosted instance (Streamable HTTP, stateless), no install:
93
102
 
94
103
  ```sh
95
104
  claude mcp add --transport http cpsc-recalls-mcp-server https://cpsc-recalls.caseyjhand.com/mcp
96
105
  ```
97
106
 
98
- For Cline (`cline_mcp_settings.json`):
107
+ Or run it locally:
99
108
 
100
- ```json
101
- {
102
- "mcpServers": {
103
- "cpsc-recalls-mcp-server": {
104
- "type": "streamableHttp",
105
- "url": "https://cpsc-recalls.caseyjhand.com/mcp"
106
- }
107
- }
108
- }
109
- ```
110
-
111
- For Cursor (`mcp.json`):
112
-
113
- ```json
114
- {
115
- "mcpServers": {
116
- "cpsc-recalls-mcp-server": {
117
- "url": "https://cpsc-recalls.caseyjhand.com/mcp"
118
- }
119
- }
120
- }
121
- ```
122
-
123
- Claude Desktop adds remote servers under Settings → Connectors.
124
-
125
- ### Self-Hosted / Local
126
-
127
- Add the following to your MCP client configuration file.
128
-
129
- ```json
130
- {
131
- "mcpServers": {
132
- "cpsc-recalls-mcp-server": {
133
- "type": "stdio",
134
- "command": "bunx",
135
- "args": ["@cyanheads/cpsc-recalls-mcp-server@latest"],
136
- "env": {
137
- "MCP_TRANSPORT_TYPE": "stdio",
138
- "MCP_LOG_LEVEL": "info"
139
- }
140
- }
141
- }
142
- }
109
+ ```sh
110
+ claude mcp add cpsc-recalls-mcp-server -- npx -y @cyanheads/cpsc-recalls-mcp-server
143
111
  ```
144
112
 
145
- Or with npx (no Bun required):
113
+ For other clients, add this to the MCP config file:
146
114
 
147
115
  ```json
148
116
  {
149
117
  "mcpServers": {
150
118
  "cpsc-recalls-mcp-server": {
151
- "type": "stdio",
152
119
  "command": "npx",
153
- "args": ["-y", "@cyanheads/cpsc-recalls-mcp-server@latest"],
154
- "env": {
155
- "MCP_TRANSPORT_TYPE": "stdio",
156
- "MCP_LOG_LEVEL": "info"
157
- }
120
+ "args": ["-y", "@cyanheads/cpsc-recalls-mcp-server"]
158
121
  }
159
122
  }
160
123
  }
161
124
  ```
162
125
 
163
- Or with Docker:
164
-
165
- ```json
166
- {
167
- "mcpServers": {
168
- "cpsc-recalls-mcp-server": {
169
- "type": "stdio",
170
- "command": "docker",
171
- "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/cpsc-recalls-mcp-server:latest"]
172
- }
173
- }
174
- }
175
- ```
176
-
177
- For Streamable HTTP, set the transport and start the server:
178
-
179
- ```sh
180
- MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
181
- # Server listens at http://localhost:3010/mcp
182
- ```
183
-
184
- ### Prerequisites
185
-
186
- - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
187
- - No API key required — the CPSC public recalls API is freely accessible.
188
-
189
- ### Installation
190
-
191
- 1. **Clone the repository:**
192
-
193
- ```sh
194
- git clone https://github.com/cyanheads/cpsc-recalls-mcp-server.git
195
- ```
196
-
197
- 2. **Navigate into the directory:**
198
-
199
- ```sh
200
- cd cpsc-recalls-mcp-server
201
- ```
202
-
203
- 3. **Install dependencies:**
204
-
205
- ```sh
206
- bun install
207
- ```
208
-
209
- 4. **Configure environment:**
210
-
211
- ```sh
212
- cp .env.example .env
213
- # edit .env if needed — no required API keys
214
- ```
126
+ No API key is required — the CPSC public recalls API is freely accessible. For Docker or a source build, see [Running the server](#running-the-server).
215
127
 
216
128
  ## Configuration
217
129
 
@@ -238,6 +150,16 @@ No server-specific API keys are required. See [`.env.example`](./.env.example) f
238
150
 
239
151
  ### Local development
240
152
 
153
+ Requires [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+). No API key is needed.
154
+
155
+ - **Clone and install:**
156
+
157
+ ```sh
158
+ git clone https://github.com/cyanheads/cpsc-recalls-mcp-server.git
159
+ cd cpsc-recalls-mcp-server && bun install
160
+ cp .env.example .env # optional — no required API keys
161
+ ```
162
+
241
163
  - **Build and run the production version:**
242
164
 
243
165
  ```sh
@@ -250,6 +172,12 @@ No server-specific API keys are required. See [`.env.example`](./.env.example) f
250
172
  bun run start:http
251
173
  ```
252
174
 
175
+ For Streamable HTTP, set the transport and port; the server listens at `http://localhost:3010/mcp`:
176
+
177
+ ```sh
178
+ MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
179
+ ```
180
+
253
181
  - **Run checks and tests:**
254
182
 
255
183
  ```sh
@@ -264,13 +192,16 @@ docker build -t cpsc-recalls-mcp-server .
264
192
  docker run --rm -p 3010:3010 cpsc-recalls-mcp-server
265
193
  ```
266
194
 
195
+ To run the published image over stdio from an MCP client, use `"command": "docker"` with `"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/cpsc-recalls-mcp-server:latest"]`.
196
+
267
197
  The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/cpsc-recalls-mcp-server`. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
268
198
 
269
199
  ## Project structure
270
200
 
271
201
  | Directory | Purpose |
272
202
  |:---|:---|
273
- | `src/index.ts` | `createApp()` entry point — registers tools and inits the CPSC service |
203
+ | `src/index.ts` | `createApp()` entry point — registers tools, serves the published skill from `skills/`, and inits the CPSC service |
204
+ | `skills/` | `cpsc-recalls-guide/SKILL.md`, the published agent skill |
274
205
  | `src/mcp-server/tools` | Tool definitions (`definitions/*.tool.ts`) — search, get-recall, get-recent — plus `response-budget.ts`, the page size budget the two list tools share, and `markdown-escape.ts`, the escaping applied to CPSC text in `content[]` |
275
206
  | `src/services/cpsc-recall` | CPSC recall service — API client, types, and `normalize-text.ts`, which converts CPSC's HTML markup and character codes to plain text |
276
207
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ summary: "Publishes the cpsc-recalls-guide Agent Skill as an MCP resource; mcp-ts-core moves to 0.13.18, which renders the search query echo as a code span and stops the HTTP transport from retaining memory on every request."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.3.0 — 2026-10-28
8
+
9
+ ## Added
10
+
11
+ - **`cpsc-recalls-guide` skill** served over MCP as the resource `skill://cpsc-recalls-guide/SKILL.md` (template `skill://{+path}`) via `createApp({ skills: 'skills' })`, and shipped in the npm package, the Docker image, and the `.mcpb`. It covers search-formulation gates, recall-number and identifier quirks, jurisdiction edges, and recall-checking workflows.
12
+ - **Agent Plugins** root `plugin.json` and `mcp.json` (mcp-ts-core 0.13.18 template).
13
+
14
+ ## Changed
15
+
16
+ - **`cpsc_search_recalls` `content[]`**: the `Query:` echo line renders as a code span instead of Markdown-escaped text, e.g. `` Query: `product_name="stroller" AND date_start=2025-01-01 AND date_end=2025-12-31` `` (mcp-ts-core 0.13.18). `structuredContent` is unchanged.
17
+ - **64,000-byte page budget** reserves room for the code-span echo and per-line notice quoting, so a cut page stays within budget for any echoed value.
18
+ - **README Getting started** is reorganized: hosted `claude mcp add`, local npx, one JSON config, with install and prerequisites under Running the server.
19
+ - **`.mcpb` bundle** leaves out repo-local files (root-anchored `.mcpbignore` from the 0.13.18 template) and the framework's build-only files (0.13.18 cleaner). It keeps the MCP SDK's skills runtime modules, which the 0.13.18 cleaner would otherwise strip (cyanheads/mcp-ts-core#794).
20
+
21
+ ## Fixed
22
+
23
+ - **HTTP transport** no longer retains about 71 KiB per request, so sustained traffic no longer grows the heap. 0.2.3 shipped on mcp-ts-core 0.13.16, which leaked it through the shutdown signal; fixed in mcp-ts-core 0.13.17 (cyanheads/mcp-ts-core#786).
24
+
25
+ ## Dependencies
26
+
27
+ - `@cyanheads/mcp-ts-core` ^0.13.16 → ^0.13.18.
28
+ - `@modelcontextprotocol/server` 2.3.1 → 2.4.0 (through the framework, 0.13.17).
29
+ - `@biomejs/biome` ^2.6.2 → ^2.6.3.
30
+ - `ignore` ^7.0.13 → ^7.0.14.
31
+ - `tsc-alias` ^1.9.8 → ^1.9.9.
32
+ - `minimumReleaseAge` 3 days → 1 day.
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ await createApp({
20
20
  tools: [cpscSearchRecalls, cpscGetRecall, cpscGetRecent],
21
21
  resources: [],
22
22
  prompts: [],
23
+ skills: 'skills',
23
24
  instructions: 'CPSC consumer product recalls from saferproducts.gov. Search with cpsc_search_recalls, which needs at least one filter (a product, brand, hazard, or other text filter, or a date bound); browse the latest recalls with cpsc_get_recent, and pass a recall number from either to cpsc_get_recall for the full record. CPSC jurisdiction: consumer products only — food/drugs (FDA), motor vehicles/tires (NHTSA), boats (USCG), pesticides (EPA), and firearms (ATF) are covered by other agencies.',
24
25
  setup(core) {
25
26
  void core;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,wDAAwD,CAAC;AACvF,OAAO,EAAE,aAAa,EAAE,MAAM,wDAAwD,CAAC;AACvF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,qBAAqB,EAAE,MAAM,+CAA+C,CAAC;AAEtF,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,yBAAyB;IAC/B,KAAK,EAAE,yBAAyB;IAChC;;;;OAIG;IACH,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE,CAAC,iBAAiB,EAAE,aAAa,EAAE,aAAa,CAAC;IACxD,SAAS,EAAE,EAAE;IACb,OAAO,EAAE,EAAE;IACX,YAAY,EACV,seAAse;IACxe,KAAK,CAAC,IAAI;QACR,KAAK,IAAI,CAAC;QACV,qBAAqB,EAAE,CAAC;IAC1B,CAAC;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,wDAAwD,CAAC;AACvF,OAAO,EAAE,aAAa,EAAE,MAAM,wDAAwD,CAAC;AACvF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,qBAAqB,EAAE,MAAM,+CAA+C,CAAC;AAEtF,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,yBAAyB;IAC/B,KAAK,EAAE,yBAAyB;IAChC;;;;OAIG;IACH,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE,CAAC,iBAAiB,EAAE,aAAa,EAAE,aAAa,CAAC;IACxD,SAAS,EAAE,EAAE;IACb,OAAO,EAAE,EAAE;IACX,MAAM,EAAE,QAAQ;IAChB,YAAY,EACV,seAAse;IACxe,KAAK,CAAC,IAAI;QACR,KAAK,IAAI,CAAC;QACV,qBAAqB,EAAE,CAAC;IAC1B,CAAC;CACF,CAAC,CAAC"}
@@ -24,9 +24,13 @@ export declare function countWithinBudget<T>(records: readonly T[], render: (rec
24
24
  * (records emptied, enrichment merged in) and the rendered non-record text plus the
25
25
  * enrichment trailer the framework appends to `content[]`.
26
26
  *
27
- * The trailer is a leading blank line, then each field behind a label of at most seven
28
- * bytes (`Query: `, `> `), separated from the next by at most a blank line — so each field
29
- * is reserved nine bytes beyond its value.
27
+ * The trailer is its own text block: a leading blank line, then one field per enrichment
28
+ * value. A `notice` renders every line behind `> `; an `echo` renders `Query: ` and a
29
+ * generic field `**key:** ` before the value as a code literal — a code span, sized past
30
+ * the value's longest backtick run, or a fenced block on lines of its own when the value
31
+ * holds a line break, and nothing when it is empty. Fields after a quote are set off by a
32
+ * blank line, the rest by a line break. Each field is reserved its largest rendering, so
33
+ * the reservation holds whichever helper set it and whatever the value holds.
30
34
  */
31
35
  export declare function pageOverheadBytes(skeleton: object, text: string, enrichment: Readonly<Record<string, string>>): number;
32
36
  /** The notice for a page the budget ended before `requested` records. */
@@ -1 +1 @@
1
- {"version":3,"file":"response-budget.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/response-budget.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAO5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,OAAO,EAAE,SAAS,CAAC,EAAE,EACrB,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,EAC7B,aAAa,EAAE,MAAM,GACpB,MAAM,CASR;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAC3C,MAAM,CASR;AAED,yEAAyE;AACzE,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/F"}
1
+ {"version":3,"file":"response-budget.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/response-budget.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAO5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,OAAO,EAAE,SAAS,CAAC,EAAE,EACrB,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,EAC7B,aAAa,EAAE,MAAM,GACpB,MAAM,CASR;AAqCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAC3C,MAAM,CASR;AAED,yEAAyE;AACzE,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/F"}
@@ -4,7 +4,6 @@
4
4
  * `structuredContent` and to the `content[]` text alike.
5
5
  * @module mcp-server/tools/response-budget
6
6
  */
7
- import { escapeMarkdownInline } from '@cyanheads/mcp-ts-core/utils';
8
7
  /**
9
8
  * UTF-8 byte cap on each response surface: the serialized `structuredContent`, and all
10
9
  * `content[]` text including the enrichment trailer.
@@ -34,17 +33,55 @@ export function countWithinBudget(records, render, overheadBytes) {
34
33
  }
35
34
  return count;
36
35
  }
36
+ /** Length of the longest backtick run in `value`; 0 when it holds none. */
37
+ function longestBacktickRun(value) {
38
+ let longest = 0;
39
+ for (const [run] of value.matchAll(/`+/g))
40
+ longest = Math.max(longest, run.length);
41
+ return longest;
42
+ }
43
+ /**
44
+ * Upper bound on the bytes the framework renders for `value` as a code literal: nothing
45
+ * for the empty string; for one line, a code span delimited by a backtick run one longer
46
+ * than the value's longest, plus a padding space on each side; for a value holding a line
47
+ * break, a fenced block on lines of its own — a line break before and after, and a fence
48
+ * at least three backticks long (one longer than the value's longest run) on each side of
49
+ * the value's own line.
50
+ */
51
+ function codeLiteralBytes(value) {
52
+ if (value === '')
53
+ return 0;
54
+ const run = longestBacktickRun(value);
55
+ return /[\r\n]/.test(value)
56
+ ? utf8Bytes(value) + 2 * Math.max(3, run + 1) + 4
57
+ : utf8Bytes(value) + 2 * (run + 1) + 2;
58
+ }
59
+ /**
60
+ * Upper bound on the bytes the framework's enrichment trailer renders for one field,
61
+ * separator included: the larger of its renderings as a `notice` (every line behind `> `)
62
+ * and as an `echo` (`Query: `) or generic (`**key:** `) line carrying the value as a code
63
+ * literal, plus the two-byte line break or blank line set before it.
64
+ */
65
+ function trailerFieldBytes(key, value) {
66
+ const lines = 1 + (value.match(/\r\n|\r|\n/g)?.length ?? 0);
67
+ const label = Math.max(utf8Bytes('Query: '), utf8Bytes(`**${key}:** `));
68
+ return 2 + Math.max(utf8Bytes(value) + 2 * lines, label + codeLiteralBytes(value));
69
+ }
37
70
  /**
38
71
  * Bytes a page spends outside its records: the larger of the `structuredContent` skeleton
39
72
  * (records emptied, enrichment merged in) and the rendered non-record text plus the
40
73
  * enrichment trailer the framework appends to `content[]`.
41
74
  *
42
- * The trailer is a leading blank line, then each field behind a label of at most seven
43
- * bytes (`Query: `, `> `), separated from the next by at most a blank line — so each field
44
- * is reserved nine bytes beyond its value.
75
+ * The trailer is its own text block: a leading blank line, then one field per enrichment
76
+ * value. A `notice` renders every line behind `> `; an `echo` renders `Query: ` and a
77
+ * generic field `**key:** ` before the value as a code literal — a code span, sized past
78
+ * the value's longest backtick run, or a fenced block on lines of its own when the value
79
+ * holds a line break, and nothing when it is empty. Fields after a quote are set off by a
80
+ * blank line, the rest by a line break. Each field is reserved its largest rendering, so
81
+ * the reservation holds whichever helper set it and whatever the value holds.
45
82
  */
46
83
  export function pageOverheadBytes(skeleton, text, enrichment) {
47
- const trailer = Object.values(enrichment).reduce((sum, value) => sum + utf8Bytes(escapeMarkdownInline(value)) + 9, 2);
84
+ const trailer = Object.entries(enrichment).reduce((sum, [key, value]) => sum + trailerFieldBytes(key, value), 0);
48
85
  return Math.max(utf8Bytes(JSON.stringify({ ...skeleton, ...enrichment })), utf8Bytes(text) + trailer);
49
86
  }
50
87
  /** The notice for a page the budget ended before `requested` records. */
@@ -1 +1 @@
1
- {"version":3,"file":"response-budget.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/response-budget.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAEpE;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC;AAE5C,mCAAmC;AACnC,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAAqB,EACrB,MAA6B,EAC7B,aAAqB;IAErB,IAAI,IAAI,GAAG,aAAa,CAAC;IACzB,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,IAAI,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACnF,IAAI,KAAK,GAAG,CAAC,IAAI,IAAI,GAAG,qBAAqB;YAAE,MAAM;QACrD,KAAK,EAAE,CAAC;IACV,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,IAAY,EACZ,UAA4C;IAE5C,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAC9C,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,GAAG,SAAS,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAChE,CAAC,CACF,CAAC;IACF,OAAO,IAAI,CAAC,GAAG,CACb,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,GAAG,UAAU,EAAE,CAAC,CAAC,EACzD,SAAS,CAAC,IAAI,CAAC,GAAG,OAAO,CAC1B,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,SAAiB,EAAE,UAAkB;IACrF,OAAO,YAAY,QAAQ,WAAW,SAAS,6CAA6C,qBAAqB,CAAC,cAAc,CAAC,OAAO,CAAC,oDAAoD,UAAU,gBAAgB,CAAC;AAC1N,CAAC"}
1
+ {"version":3,"file":"response-budget.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/response-budget.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC;AAE5C,mCAAmC;AACnC,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAAqB,EACrB,MAA6B,EAC7B,aAAqB;IAErB,IAAI,IAAI,GAAG,aAAa,CAAC;IACzB,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,IAAI,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACnF,IAAI,KAAK,GAAG,CAAC,IAAI,IAAI,GAAG,qBAAqB;YAAE,MAAM;QACrD,KAAK,EAAE,CAAC;IACV,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,2EAA2E;AAC3E,SAAS,kBAAkB,CAAC,KAAa;IACvC,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,MAAM,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,gBAAgB,CAAC,KAAa;IACrC,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,CAAC,CAAC;IAC3B,MAAM,GAAG,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;IACtC,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC;QACzB,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC;QACjD,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;AAC3C,CAAC;AAED;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,GAAW,EAAE,KAAa;IACnD,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC;IACxE,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,EAAE,KAAK,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC;AACrF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,IAAY,EACZ,UAA4C;IAE5C,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,MAAM,CAC/C,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,iBAAiB,CAAC,GAAG,EAAE,KAAK,CAAC,EAC1D,CAAC,CACF,CAAC;IACF,OAAO,IAAI,CAAC,GAAG,CACb,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,GAAG,UAAU,EAAE,CAAC,CAAC,EACzD,SAAS,CAAC,IAAI,CAAC,GAAG,OAAO,CAC1B,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,SAAiB,EAAE,UAAkB;IACrF,OAAO,YAAY,QAAQ,WAAW,SAAS,6CAA6C,qBAAqB,CAAC,cAAc,CAAC,OAAO,CAAC,oDAAoD,UAAU,gBAAgB,CAAC;AAC1N,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/cpsc-recalls-mcp-server",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.cyanheads/cpsc-recalls-mcp-server",
5
5
  "description": "Search and retrieve US consumer product recalls from the CPSC (Consumer Product Safety Commission) via MCP. STDIO or Streamable HTTP.",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "CLAUDE.md",
19
19
  "AGENTS.md",
20
20
  "Dockerfile",
21
- "server.json"
21
+ "server.json",
22
+ "skills/"
22
23
  ],
23
24
  "scripts": {
24
25
  "build": "bun run scripts/build.ts",
@@ -87,19 +88,19 @@
87
88
  "access": "public"
88
89
  },
89
90
  "dependencies": {
90
- "@cyanheads/mcp-ts-core": "^0.13.16",
91
+ "@cyanheads/mcp-ts-core": "^0.13.18",
91
92
  "pino-pretty": "^13.2.0",
92
93
  "zod": "^4.6.7"
93
94
  },
94
95
  "devDependencies": {
95
- "@biomejs/biome": "^2.6.2",
96
+ "@biomejs/biome": "^2.6.3",
96
97
  "@socketsecurity/bun-security-scanner": "^1.1.3",
97
98
  "@types/markdown-it": "^14.2.0",
98
99
  "@types/node": "^26.6.5",
99
100
  "depcheck": "^1.4.7",
100
- "ignore": "^7.0.13",
101
+ "ignore": "^7.0.14",
101
102
  "markdown-it": "^15.0.3",
102
- "tsc-alias": "^1.9.8",
103
+ "tsc-alias": "^1.9.9",
103
104
  "typescript": "^7.0.2",
104
105
  "vitest": "^5.0.5"
105
106
  }
package/server.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "https://github.com/cyanheads/cpsc-recalls-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.3",
9
+ "version": "0.3.0",
10
10
  "remotes": [
11
11
  {
12
12
  "type": "streamable-http",
@@ -19,7 +19,7 @@
19
19
  "registryBaseUrl": "https://registry.npmjs.org",
20
20
  "identifier": "@cyanheads/cpsc-recalls-mcp-server",
21
21
  "runtimeHint": "npx",
22
- "version": "0.2.3",
22
+ "version": "0.3.0",
23
23
  "environmentVariables": [
24
24
  {
25
25
  "name": "MCP_LOG_LEVEL",
@@ -38,7 +38,7 @@
38
38
  "registryBaseUrl": "https://registry.npmjs.org",
39
39
  "identifier": "@cyanheads/cpsc-recalls-mcp-server",
40
40
  "runtimeHint": "npx",
41
- "version": "0.2.3",
41
+ "version": "0.3.0",
42
42
  "environmentVariables": [
43
43
  {
44
44
  "name": "MCP_TRANSPORT_TYPE",
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: cpsc-recalls-guide
3
+ description: Find and confirm US consumer product recalls with the cpsc_* tools. Use when checking whether a specific product, model, serial, or UPC was recalled, sweeping a brand's or retailer's recall history, monitoring new or republished recalls for a hazard, or deciding whether a product is CPSC's at all. Covers search formulation, recall-number quirks, and when an empty result proves nothing.
4
+ license: Apache-2.0
5
+ metadata:
6
+ author: cyanheads
7
+ version: "1.0"
8
+ ---
9
+
10
+ ## Scope
11
+
12
+ CPSC recall records from saferproducts.gov, 1973 to the present, keyless. The server reads recall records only, so an empty result means no recall matched, never that a product is safe.
13
+
14
+ The jurisdiction note on every successful response names the agencies that own food, drugs, on-road vehicles, boats, pesticides, and firearms. CPSC still holds recalls for products next to those lines: drugs and dietary supplements recalled for failing child-resistant packaging rules, all-terrain vehicles and snowmobiles, e-bikes and bicycle tires, gun safes and locks, and car-seat adapters and accessories (some car seat recalls were announced jointly with NHTSA). Search here before routing such a product away. For a car seat or a tire, check the vehicle-safety agency's recalls as well.
15
+
16
+ ## Identifiers
17
+
18
+ - **Recall number.** Comes from cpsc_search_recalls or cpsc_get_recent. Pass five digits with no separator; a hyphenated form such as `25-043` fails validation. The first two digits are almost always the federal fiscal year the recall was issued in, which starts October 1 (`27xxx` numbers begin in October 2026, `03064` was issued in late 2002). Use that to bound a date search when a number is all you have.
19
+ - **Lettered numbers.** Some 1998–2001 releases are split into lettered records (`01160a`, `01160b`, `01160c`) that are unrelated recalls issued the same day. The bare base number is not a record, and cpsc_get_recall on it returns one of the lettered siblings instead of failing. Check that the returned recall number equals the one you asked for; to see every sibling, search with date_start and date_end both set to that day.
20
+ - **Model numbers, serials, UPCs.** No filter matches them as fields. They live in the description text that cpsc_get_recall returns; see the unit-level gate below.
21
+
22
+ ## Gates
23
+
24
+ 1. **One empty search is not "not recalled".** Before telling a user no recall exists, run title_search with brand plus product, then a company-role filter (manufacturer, importer, or retailer) with product_name, and merge the two result sets by recall number. They diverge: for Graco strollers, title_search found a recall where Graco is listed only as importer, and the role search found a recall whose title names activity trays while a product name says stroller. Report "no CPSC recall found" and the searches you ran, never "safe".
25
+
26
+ 2. **Matching is substring, with no stemming.** A filter word matches inside longer words and misses other inflections. `fire` returns firearm safes, fireworks, and fireplace products; `burn` matches "burner"; `battery` misses hazard text that only says "batteries". Search the shortest distinctive stem (`batter`, `ingest`, `strangul`) and read each hit's hazard text before counting it toward a hazard class.
27
+
28
+ 3. **hazard_search never reads the title, and the hazard field can be wrong.** One toy recall titled for battery ingestion carries "None reported." as its hazard text, and its data_quality_notes are empty, because those notes flag only fields CPSC left blank. For a hazard sweep, run the same stem through title_search too and merge by recall number.
29
+
30
+ 4. **Pair hazard_search with something narrower.** A hazard_search with no other criterion downloads the whole recall history before filtering, which can take many seconds when CPSC's cache is cold. Add a date bound or a product, title, or company filter when the question allows.
31
+
32
+ 5. **Unit-level checks happen by reading.** Descriptions state models, serials, and date codes as ranges or masks (`1HFVE05**K4000003` to `1HFVE05**K4003902`, `111522DXXXXXX`, serial runs like `3591767-3591769`), so a specific serial rarely matches as text. Search a model family or a distinctive digit run with description_search, open the candidates with cpsc_get_recall, and compare the user's unit against the stated ranges yourself.
33
+
34
+ 6. **A UPC is findable only as the description prints it.** Most UPC-bearing descriptions repeat the code, but formatting varies: `082294319754` found nothing where the description prints `0-82294-319754`, while `319754` found it. Search a run of six or more trailing digits, then confirm the full code in the record.
35
+
36
+ 7. **units_recalled is narrative, not a count.** It is approximate ("About 61,000"), often adds Canadian or Mexican sales in parentheses, and on an expanded or re-announced recall it can cite or already include units from the earlier recall ("About 569,000 (In August 2018, 105,000 of the 569,000 blending containers and blade bases were previously recalled)"). Read the parenthetical before summing across recalls, and present totals as approximate.
37
+
38
+ 8. **Issue date and last-published date are separate axes.** cpsc_get_recent windows on issue date. A record CPSC republishes keeps its original issue date, so a recent window or an issue-date search misses it and updated_start and updated_end find it; 1998 recalls republished in October 2026 appear that way. Search rows don't carry the last-published date. cpsc_get_recall shows it as last_updated, but nothing says what changed.
39
+
40
+ ## Workflows
41
+
42
+ **Is this product recalled?** Run gate 1's searches, narrow the candidates by title and issue date against when the user bought the product, open each with cpsc_get_recall, and compare model, serial, UPC, and the retailers' sale-date and price narrative against the user's unit (gates 5 and 6). Answer with the remedy text, the consumer contact, and cpsc_url so the user can verify.
43
+
44
+ **A company's or retailer's history.** The retailer filter matches CPSC's free-text sale narrative ("Amazon.com from March 2025 through August 2026 for about $13 to $14"), so it also reaches online marketplaces. For a brand, combine title_search with the manufacturer and importer filters (gate 1). Expansions and re-announcements are separate recalls with their own numbers, titled "Expands Recall" or "Reannounces"; title_search on brand plus product returns the original next to them.
45
+
46
+ **Tracing one recall's history.** Start from a title_search on brand plus product, not a model number. A model named in the original recall's description can appear in the expansion only inside units_recalled: `2551W` returns the original BISSELL wet-dry vacuum recall from January 2023 through description_search, while title_search "BISSELL Wet Dry" returns both it and the April 2023 expansion.
47
+
48
+ **Monitoring.** For a calendar window ("this month"), call cpsc_search_recalls with date_start and date_end; a date bound alone satisfies its criterion requirement, and it accepts a hazard or company filter. cpsc_get_recent covers only a rolling window ending today and takes no filters. Key stored results by recall number, and add an updated_start pass to catch records CPSC republishes after the fact.