@cyanheads/cpsc-recalls-mcp-server 0.2.2 → 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.
Files changed (28) hide show
  1. package/AGENTS.md +29 -104
  2. package/CLAUDE.md +29 -104
  3. package/Dockerfile +17 -10
  4. package/README.md +46 -93
  5. package/changelog/0.2.x/0.2.3.md +29 -0
  6. package/changelog/0.3.x/0.3.0.md +32 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.js +5 -4
  11. package/dist/mcp-server/tools/definitions/cpsc-get-recall.tool.js.map +1 -1
  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 +3 -2
  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.map +1 -1
  16. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.js +4 -3
  17. package/dist/mcp-server/tools/definitions/cpsc-search-recalls.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/markdown-escape.d.ts +0 -14
  19. package/dist/mcp-server/tools/markdown-escape.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/markdown-escape.js +0 -28
  21. package/dist/mcp-server/tools/markdown-escape.js.map +1 -1
  22. package/dist/mcp-server/tools/response-budget.d.ts +7 -3
  23. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/response-budget.js +42 -4
  25. package/dist/mcp-server/tools/response-budget.js.map +1 -1
  26. package/package.json +13 -10
  27. package/server.json +3 -3
  28. package/skills/cpsc-recalls-guide/SKILL.md +48 -0
package/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** cpsc-recalls-mcp-server
4
- **Version:** 0.2.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
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.2.0 (protocol revisions 2026-07-28 and 2025-*)
8
- **Zod:** ^4.6.5
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (protocol revisions 2026-07-28 and 2025-*)
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.
11
11
 
@@ -44,105 +44,15 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
44
44
 
45
45
  ## Patterns
46
46
 
47
- ### Tool
47
+ The three registered tools live in `src/mcp-server/tools/definitions/`:
48
48
 
49
- ```ts
50
- import { tool, z } from '@cyanheads/mcp-ts-core';
51
-
52
- export const searchItems = tool('search_items', {
53
- description: 'Search inventory items by query.',
54
- annotations: { readOnlyHint: true },
55
- input: z.object({
56
- query: z.string().describe('Search terms'),
57
- limit: z.number().int().min(1).max(100).default(10).describe('Max results (1–100)'),
58
- }),
59
- output: z.object({
60
- items: z.array(z.object({
61
- id: z.string().describe('Item ID'),
62
- name: z.string().describe('Item name'),
63
- })).describe('Matching items'),
64
- }),
65
- auth: ['inventory:read'],
66
-
67
- async handler(input, ctx) {
68
- const items = await findItems(input.query, input.limit);
69
- ctx.log.info('Search completed', { query: input.query, count: items.length });
70
- return { items };
71
- },
72
-
73
- // format() populates content[] — the markdown twin of structuredContent.
74
- // Different clients read different surfaces (Claude Code → structuredContent,
75
- // Claude Desktop → content[]); both must carry the same data.
76
- // Enforced at lint time: every field in `output` must appear in the rendered text.
77
- format: (result) => [{
78
- type: 'text',
79
- text: result.items.map(i => `**${i.id}**: ${i.name}`).join('\n'),
80
- }],
81
- });
82
- ```
83
-
84
- ### Resource
85
-
86
- ```ts
87
- import { resource, z } from '@cyanheads/mcp-ts-core';
88
- import { notFound } from '@cyanheads/mcp-ts-core/errors';
89
-
90
- export const itemData = resource('inventory://{itemId}', {
91
- description: 'Fetch an inventory item by ID.',
92
- params: z.object({ itemId: z.string().describe('Item identifier') }),
93
- auth: ['inventory:read'],
94
- async handler(params, ctx) {
95
- const item = await ctx.state.get(`item/${params.itemId}`);
96
- if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
97
- return item;
98
- },
99
- });
100
- ```
101
-
102
- ### Prompt
103
-
104
- ```ts
105
- import { prompt, z } from '@cyanheads/mcp-ts-core';
106
-
107
- export const reviewCode = prompt('review_code', {
108
- description: 'Review code for issues and best practices.',
109
- args: z.object({
110
- code: z.string().describe('Code to review'),
111
- language: z.string().optional().describe('Programming language'),
112
- }),
113
- generate: (args) => [
114
- { role: 'user', content: { type: 'text', text: `Review this ${args.language ?? ''} code:\n${args.code}` } },
115
- ],
116
- });
117
- ```
49
+ - `cpsc-search-recalls.tool.ts` applies text/date filters and offset pagination; its record renderer also supplies the byte-budget calculation.
50
+ - `cpsc-get-recall.tool.ts` expands a recall number into the full record and distinguishes quoted CPSC narrative from server guidance.
51
+ - `cpsc-get-recent.tool.ts` browses a date window with the same pagination and response-budget contract as search.
118
52
 
119
- ### Server config
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`).
120
54
 
121
- ```ts
122
- // src/config/server-config.ts — lazy-parsed, separate from framework config
123
- import { z } from '@cyanheads/mcp-ts-core';
124
- import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
125
-
126
- const ServerConfigSchema = z.object({
127
- apiKey: z.string().describe('External API key'),
128
- maxResults: z.coerce.number().default(100),
129
- verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
130
- });
131
-
132
- let _config: z.infer<typeof ServerConfigSchema> | undefined;
133
- export function getServerConfig() {
134
- _config ??= parseEnvConfig(ServerConfigSchema, {
135
- apiKey: 'MY_API_KEY',
136
- maxResults: 'MY_MAX_RESULTS',
137
- verboseLogging: 'MY_VERBOSE_LOGGING',
138
- });
139
- return _config;
140
- }
141
- ```
142
-
143
- `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
144
-
145
- For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
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`.
146
56
 
147
57
  ### Server identity and instructions
148
58
 
@@ -266,6 +176,8 @@ src/
266
176
 
267
177
  ## Naming
268
178
 
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.
180
+
269
181
  | What | Convention | Example |
270
182
  |:-----|:-----------|:--------|
271
183
  | Files | kebab-case with suffix | `search-docs.tool.ts` |
@@ -277,9 +189,18 @@ src/
277
189
 
278
190
  ## Skills
279
191
 
280
- 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.
281
202
 
282
- **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.
283
204
 
284
205
  Available skills:
285
206
 
@@ -293,6 +214,7 @@ Available skills:
293
214
  | `add-prompt` | Scaffold a new prompt definition |
294
215
  | `add-service` | Scaffold a new service integration |
295
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 |
296
218
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
297
219
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
298
220
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
@@ -338,7 +260,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
338
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` |
339
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` |
340
262
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
341
- | `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) |
342
264
  | `bun run list-skills` | Print the skill registry |
343
265
  | `bun run tree` | Generate directory structure doc |
344
266
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -356,7 +278,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
356
278
 
357
279
  ## Bundling
358
280
 
359
- `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.
360
282
 
361
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": ""`.
362
284
 
@@ -423,7 +345,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
423
345
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
424
346
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
425
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"`
426
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
427
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
428
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
429
354
  - [ ] `bun run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** cpsc-recalls-mcp-server
4
- **Version:** 0.2.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
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.2.0 (protocol revisions 2026-07-28 and 2025-*)
8
- **Zod:** ^4.6.5
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 (protocol revisions 2026-07-28 and 2025-*)
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.
11
11
 
@@ -44,105 +44,15 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
44
44
 
45
45
  ## Patterns
46
46
 
47
- ### Tool
47
+ The three registered tools live in `src/mcp-server/tools/definitions/`:
48
48
 
49
- ```ts
50
- import { tool, z } from '@cyanheads/mcp-ts-core';
51
-
52
- export const searchItems = tool('search_items', {
53
- description: 'Search inventory items by query.',
54
- annotations: { readOnlyHint: true },
55
- input: z.object({
56
- query: z.string().describe('Search terms'),
57
- limit: z.number().int().min(1).max(100).default(10).describe('Max results (1–100)'),
58
- }),
59
- output: z.object({
60
- items: z.array(z.object({
61
- id: z.string().describe('Item ID'),
62
- name: z.string().describe('Item name'),
63
- })).describe('Matching items'),
64
- }),
65
- auth: ['inventory:read'],
66
-
67
- async handler(input, ctx) {
68
- const items = await findItems(input.query, input.limit);
69
- ctx.log.info('Search completed', { query: input.query, count: items.length });
70
- return { items };
71
- },
72
-
73
- // format() populates content[] — the markdown twin of structuredContent.
74
- // Different clients read different surfaces (Claude Code → structuredContent,
75
- // Claude Desktop → content[]); both must carry the same data.
76
- // Enforced at lint time: every field in `output` must appear in the rendered text.
77
- format: (result) => [{
78
- type: 'text',
79
- text: result.items.map(i => `**${i.id}**: ${i.name}`).join('\n'),
80
- }],
81
- });
82
- ```
83
-
84
- ### Resource
85
-
86
- ```ts
87
- import { resource, z } from '@cyanheads/mcp-ts-core';
88
- import { notFound } from '@cyanheads/mcp-ts-core/errors';
89
-
90
- export const itemData = resource('inventory://{itemId}', {
91
- description: 'Fetch an inventory item by ID.',
92
- params: z.object({ itemId: z.string().describe('Item identifier') }),
93
- auth: ['inventory:read'],
94
- async handler(params, ctx) {
95
- const item = await ctx.state.get(`item/${params.itemId}`);
96
- if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
97
- return item;
98
- },
99
- });
100
- ```
101
-
102
- ### Prompt
103
-
104
- ```ts
105
- import { prompt, z } from '@cyanheads/mcp-ts-core';
106
-
107
- export const reviewCode = prompt('review_code', {
108
- description: 'Review code for issues and best practices.',
109
- args: z.object({
110
- code: z.string().describe('Code to review'),
111
- language: z.string().optional().describe('Programming language'),
112
- }),
113
- generate: (args) => [
114
- { role: 'user', content: { type: 'text', text: `Review this ${args.language ?? ''} code:\n${args.code}` } },
115
- ],
116
- });
117
- ```
49
+ - `cpsc-search-recalls.tool.ts` applies text/date filters and offset pagination; its record renderer also supplies the byte-budget calculation.
50
+ - `cpsc-get-recall.tool.ts` expands a recall number into the full record and distinguishes quoted CPSC narrative from server guidance.
51
+ - `cpsc-get-recent.tool.ts` browses a date window with the same pagination and response-budget contract as search.
118
52
 
119
- ### Server config
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`).
120
54
 
121
- ```ts
122
- // src/config/server-config.ts — lazy-parsed, separate from framework config
123
- import { z } from '@cyanheads/mcp-ts-core';
124
- import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
125
-
126
- const ServerConfigSchema = z.object({
127
- apiKey: z.string().describe('External API key'),
128
- maxResults: z.coerce.number().default(100),
129
- verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
130
- });
131
-
132
- let _config: z.infer<typeof ServerConfigSchema> | undefined;
133
- export function getServerConfig() {
134
- _config ??= parseEnvConfig(ServerConfigSchema, {
135
- apiKey: 'MY_API_KEY',
136
- maxResults: 'MY_MAX_RESULTS',
137
- verboseLogging: 'MY_VERBOSE_LOGGING',
138
- });
139
- return _config;
140
- }
141
- ```
142
-
143
- `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
144
-
145
- For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
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`.
146
56
 
147
57
  ### Server identity and instructions
148
58
 
@@ -266,6 +176,8 @@ src/
266
176
 
267
177
  ## Naming
268
178
 
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.
180
+
269
181
  | What | Convention | Example |
270
182
  |:-----|:-----------|:--------|
271
183
  | Files | kebab-case with suffix | `search-docs.tool.ts` |
@@ -277,9 +189,18 @@ src/
277
189
 
278
190
  ## Skills
279
191
 
280
- 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.
281
202
 
282
- **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.
283
204
 
284
205
  Available skills:
285
206
 
@@ -293,6 +214,7 @@ Available skills:
293
214
  | `add-prompt` | Scaffold a new prompt definition |
294
215
  | `add-service` | Scaffold a new service integration |
295
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 |
296
218
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
297
219
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
298
220
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
@@ -338,7 +260,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
338
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` |
339
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` |
340
262
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
341
- | `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) |
342
264
  | `bun run list-skills` | Print the skill registry |
343
265
  | `bun run tree` | Generate directory structure doc |
344
266
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -356,7 +278,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
356
278
 
357
279
  ## Bundling
358
280
 
359
- `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.
360
282
 
361
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": ""`.
362
284
 
@@ -423,7 +345,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
423
345
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
424
346
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
425
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"`
426
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
427
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
428
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
429
354
  - [ ] `bun run devcheck` passes
package/Dockerfile CHANGED
@@ -14,7 +14,7 @@
14
14
  # output. A stage that compiles a native addon needs the target-arch toolchain
15
15
  # and cannot cross-compile this way — drop the flag there.
16
16
  # ==============================================================================
17
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS build
17
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS build
18
18
 
19
19
  WORKDIR /usr/src/app
20
20
 
@@ -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,13 +45,13 @@ 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.
48
53
  # ==============================================================================
49
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS deps
54
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS deps
50
55
 
51
56
  WORKDIR /usr/src/app
52
57
 
@@ -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
 
@@ -113,7 +119,7 @@ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
113
119
  # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
114
120
  # and CMD, which run on the real target at container start.
115
121
  # ==============================================================================
116
- FROM oven/bun:1.4.2-slim AS production
122
+ FROM oven/bun:1.4.4-slim AS production
117
123
 
118
124
  WORKDIR /usr/src/app
119
125
 
@@ -123,7 +129,7 @@ ENV NODE_ENV=production
123
129
  # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
124
130
  ARG APP_VERSION
125
131
  LABEL org.opencontainers.image.title="cpsc-recalls-mcp-server"
126
- LABEL org.opencontainers.image.description="MCP server for US consumer product recalls from the Consumer Product Safety Commission: hazards, remedies, and affected products."
132
+ LABEL org.opencontainers.image.description="Search and retrieve US consumer product recalls from the CPSC (Consumer Product Safety Commission) via MCP. STDIO or Streamable HTTP."
127
133
  LABEL org.opencontainers.image.licenses="Apache-2.0"
128
134
  LABEL org.opencontainers.image.version="${APP_VERSION}"
129
135
  LABEL org.opencontainers.image.source="https://github.com/cyanheads/cpsc-recalls-mcp-server"
@@ -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.