@cyanheads/openfoodfacts-mcp-server 0.3.9 → 0.4.1

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 (39) hide show
  1. package/AGENTS.md +20 -7
  2. package/CLAUDE.md +20 -7
  3. package/Dockerfile +6 -0
  4. package/README.md +39 -125
  5. package/changelog/0.4.x/0.4.0.md +30 -0
  6. package/changelog/0.4.x/0.4.1.md +30 -0
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.js +17 -7
  11. package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/compare-products.tool.d.ts +17 -6
  13. package/dist/mcp-server/tools/definitions/compare-products.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/compare-products.tool.js +96 -61
  15. package/dist/mcp-server/tools/definitions/compare-products.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-product.tool.d.ts +2 -2
  17. package/dist/mcp-server/tools/definitions/get-product.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-product.tool.js +73 -28
  19. package/dist/mcp-server/tools/definitions/get-product.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/index.d.ts +22 -10
  21. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/search-products.tool.d.ts +3 -2
  23. package/dist/mcp-server/tools/definitions/search-products.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/search-products.tool.js +115 -53
  25. package/dist/mcp-server/tools/definitions/search-products.tool.js.map +1 -1
  26. package/dist/services/openfoodfacts/openfoodfacts-service.d.ts +14 -14
  27. package/dist/services/openfoodfacts/openfoodfacts-service.d.ts.map +1 -1
  28. package/dist/services/openfoodfacts/openfoodfacts-service.js +205 -78
  29. package/dist/services/openfoodfacts/openfoodfacts-service.js.map +1 -1
  30. package/dist/services/openfoodfacts/types.d.ts +27 -4
  31. package/dist/services/openfoodfacts/types.d.ts.map +1 -1
  32. package/dist/services/openfoodfacts/types.js +15 -0
  33. package/dist/services/openfoodfacts/types.js.map +1 -1
  34. package/dist/services/taxonomy/taxonomy-service.d.ts.map +1 -1
  35. package/dist/services/taxonomy/taxonomy-service.js +12 -10
  36. package/dist/services/taxonomy/taxonomy-service.js.map +1 -1
  37. package/package.json +8 -7
  38. package/server.json +3 -3
  39. package/skills/openfoodfacts-guide/SKILL.md +41 -0
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openfoodfacts-mcp-server
4
- **Version:** 0.3.9
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.16`
4
+ **Version:** 0.4.1
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
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
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.
@@ -239,9 +239,18 @@ src/
239
239
 
240
240
  ## Skills
241
241
 
242
- 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.
242
+ Two skill trees live at the project root, written for different readers:
243
243
 
244
- **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.
244
+ | Tree | For | Written by |
245
+ |:-----|:----|:-----------|
246
+ | `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) |
247
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent by default; usually none is needed |
248
+
249
+ 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.
250
+
251
+ 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.
252
+
253
+ **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.
245
254
 
246
255
  Available skills:
247
256
 
@@ -253,6 +262,7 @@ Available skills:
253
262
  | `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
254
263
  | `add-resource` | Scaffold a new resource definition |
255
264
  | `add-prompt` | Scaffold a new prompt definition |
265
+ | `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 |
256
266
  | `add-service` | Scaffold a new service integration |
257
267
  | `add-test` | Scaffold test file for a tool, resource, or service |
258
268
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
@@ -300,7 +310,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
300
310
  | `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` |
301
311
  | `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` |
302
312
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
303
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
313
+ | `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) |
304
314
  | `bun run list-skills` | Print the skill registry |
305
315
  | `bun run tree` | Generate directory structure doc |
306
316
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -318,7 +328,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
318
328
 
319
329
  ## Bundling
320
330
 
321
- `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. A server using DataCanvas therefore ships a portable bundle without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so canvas tools report an actionable install hint and every other tool works normally. 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.
331
+ `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/`, `SKILL.md`); 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. A server using DataCanvas therefore ships a portable bundle without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so canvas tools report an actionable install hint and every other tool works normally. 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.
322
332
 
323
333
  **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": ""`.
324
334
 
@@ -385,7 +395,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
385
395
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
386
396
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
387
397
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
398
+ - [ ] `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"`
388
399
  - [ ] `.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
389
400
  - [ ] `.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
390
401
  - [ ] `.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`
402
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
403
+ - [ ] 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
391
404
  - [ ] `npm run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openfoodfacts-mcp-server
4
- **Version:** 0.3.9
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.16`
4
+ **Version:** 0.4.1
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
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
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.
@@ -239,9 +239,18 @@ src/
239
239
 
240
240
  ## Skills
241
241
 
242
- 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.
242
+ Two skill trees live at the project root, written for different readers:
243
243
 
244
- **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.
244
+ | Tree | For | Written by |
245
+ |:-----|:----|:-----------|
246
+ | `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) |
247
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent by default; usually none is needed |
248
+
249
+ 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.
250
+
251
+ 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.
252
+
253
+ **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.
245
254
 
246
255
  Available skills:
247
256
 
@@ -253,6 +262,7 @@ Available skills:
253
262
  | `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
254
263
  | `add-resource` | Scaffold a new resource definition |
255
264
  | `add-prompt` | Scaffold a new prompt definition |
265
+ | `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 |
256
266
  | `add-service` | Scaffold a new service integration |
257
267
  | `add-test` | Scaffold test file for a tool, resource, or service |
258
268
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
@@ -300,7 +310,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
300
310
  | `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` |
301
311
  | `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` |
302
312
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
303
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
313
+ | `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) |
304
314
  | `bun run list-skills` | Print the skill registry |
305
315
  | `bun run tree` | Generate directory structure doc |
306
316
  | `bun run format` | Auto-fix formatting (safe fixes only) |
@@ -318,7 +328,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
318
328
 
319
329
  ## Bundling
320
330
 
321
- `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. A server using DataCanvas therefore ships a portable bundle without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so canvas tools report an actionable install hint and every other tool works normally. 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.
331
+ `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/`, `SKILL.md`); 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. A server using DataCanvas therefore ships a portable bundle without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so canvas tools report an actionable install hint and every other tool works normally. 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.
322
332
 
323
333
  **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": ""`.
324
334
 
@@ -385,7 +395,10 @@ import { getMyService } from '@/services/my-domain/my-service.js';
385
395
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
386
396
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
387
397
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
398
+ - [ ] `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"`
388
399
  - [ ] `.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
389
400
  - [ ] `.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
390
401
  - [ ] `.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`
402
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
403
+ - [ ] 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
391
404
  - [ ] `npm 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
 
@@ -136,6 +141,7 @@ COPY --from=deps /usr/src/app/node_modules ./node_modules
136
141
 
137
142
  # Copy the compiled application code from the build stage
138
143
  COPY --from=build /usr/src/app/dist ./dist
144
+ COPY --from=build /usr/src/app/skills ./skills
139
145
 
140
146
  # The 'oven/bun' image already provides a non-root user named 'bun'.
141
147
  # We will use this existing user for enhanced security.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.3.9-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/openfoodfacts-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/openfoodfacts-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openfoodfacts-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.4.1-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/openfoodfacts-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/openfoodfacts-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openfoodfacts-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
 
@@ -40,20 +40,28 @@ Packaged-food data from Open Food Facts, a crowd-sourced database of 3M+ product
40
40
  | `off_compare_products` | Compare up to 10 products per call by barcode on nutrition (per 100 g, or per 100 ml for liquids), Nutri-Score, NOVA, and Green-Score |
41
41
  | `off_browse_taxonomy` | Resolve a human term to the canonical tag ID that `off_search_products` filters on |
42
42
 
43
+ ### Skills
44
+
45
+ | Skill | Description |
46
+ |:------|:------------|
47
+ | `openfoodfacts-guide` | Judgment for calling the tools: allergy checks that never overclaim, choosing among a product's barcodes, where tag IDs come from, grade ranges, free-from searches, and the shared search budget. Served over MCP as `skill://openfoodfacts-guide/SKILL.md` and shipped in the plugin, npm package, and `.mcpb` bundle. |
48
+
43
49
  ## Capability reference
44
50
 
45
51
  ### `off_get_product` <sub>tool</sub>
46
52
 
47
- - `barcode` is digits only, 4–40 digits after any leading zeros; optional `fields` trims the response and pulls in what a field depends on (`nutriments` brings the serving size), with `requested_fields` echoing what was fetched
53
+ - `barcode` is digits only, 4–40 digits after any leading zeros; optional `fields` trims the response and pulls in what a field depends on (`nutriments` brings the serving size, quantity, and unit, and `serving_quantity` and `serving_quantity_unit` each bring the other plus the serving size), with `requested_fields` echoing what was fetched
48
54
  - Returns ingredients (raw text plus a parsed tree up to three levels deep with `percent_estimate`), `allergens_tags`, `traces_tags`, `additives_tags`, `ingredients_analysis_tags` (vegan, vegetarian, palm-oil verdicts), `nutriscore_grade`, `nova_group`, `ecoscore_grade` (Green-Score), `nutriments` (per serving, and per 100 g, or per 100 ml for liquids), category/label/packaging/origin/country tags, `image_url`, and `completeness` (0–1)
49
55
  - `traces_tags: ["en:none"]` means the label states no traces, while an empty array means none entered yet; a barcode no contributor has recorded fails as `not_found`
56
+ - Open Food Facts returns one preparation state of the nutrition per read. A read of prepared-product figures puts them under keys ending in `_prepared` in `additional_100g` and `additional_serving`, prints them in "as prepared" sections, and leaves the named as-sold nutrient fields absent; the text says the read returned only prepared figures, not that the record holds no as-sold ones
50
57
 
51
58
  ---
52
59
 
53
60
  ### `off_search_products` <sub>tool</sub>
54
61
 
55
62
  - `query` (up to 24 words, all of which must match the name, generic name, categories, labels, or brand; punctuation separates words, so `milk-chocolate` requires both) plus the tag filters `categories_tag`, `brands_tag`, `labels_tag` (up to 10), `allergens_tag`, `traces_tag`, `ingredients_analysis_tag`, `additives_tag`, `nutrition_grade`, `nova_group`, `countries_tag`, the `exclude_allergens` / `exclude_traces` lists (up to 14 each), and up to 18 `nutrient_filters` (per 100 g, or per 100 ml for liquids), all combined as AND; optional `sort_by`
56
- - `page_size` defaults to 20 and is served at up to 50 per call; a larger value (up to 10,000) is served at 50 and flagged in `page_size_capped`, and `page` counts pages of 50. Rows carry `barcode`, `product_name`, `brands`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, and `categories_tags`, plus `total`, `total_is_lower_bound`, `last_page`, `omitted`, and `truncated` (more matches beyond this page). Exclusions add `exclusion_coverage`, since products with no allergen data entered pass them; `nutrient_filters` add `indexed_nutriments` (the index values the filters checked, which can differ from `off_get_product`)
63
+ - `page_size` defaults to 20 and is served at up to 50 per call; a larger value, with no ceiling, is served at 50 and flagged in `page_size_capped`, and `page` counts pages of 50. Rows carry `barcode`, `product_name`, `brands`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, and `categories_tags`, plus `total`, `total_is_lower_bound`, `last_page`, `omitted`, and `truncated` (more matches beyond this page). Exclusions add `exclusion_coverage`, since products with no allergen data entered pass them; `nutrient_filters` add `indexed_nutriments` (the index values the filters checked, which can differ from `off_get_product`)
64
+ - The text index compares `energy-kcal`, `fat`, `saturated-fat`, `carbohydrates`, and `sugars` as whole numbers (`sugars lte 3` returns a row at 3.53). A `nutrient_filters` search sends `gt` and `gte` on those five as `[floor(value) TO *]` so no match is lost, then checks every returned row against every constraint at the index's 32-bit precision. Rows that fail leave the page and count in `outside_constraints`, so a page can come back short or empty while later pages hold matches. `total`, `total_is_lower_bound`, and `last_page` describe the index's whole-number answer and still count those rows. A range inside one whole number (`sugars gt 3.9` and `lt 4`) can pass few of the 10,000 reachable rows; add `query` or tag filters to narrow it
57
65
  - `query` or `nutrient_filters` searches a text index that lags the live database (flagged in `text_index_snapshot`), reaches only the first 10,000 results, and rejects `additives_tag` as `additives_filter_needs_tag_search`; tag-only searches read the live database through page 10. A page past either limit fails as `page_out_of_range`
58
66
 
59
67
  ---
@@ -61,14 +69,15 @@ Packaged-food data from Open Food Facts, a crowd-sourced database of 3M+ product
61
69
  ### `off_compare_products` <sub>tool</sub>
62
70
 
63
71
  - 2–100 `barcodes`, one row each in input order; repeated barcodes are compared once, and those past the 10th distinct one are listed in `unprocessed` without being fetched
64
- - Rows carry `found`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, energy, fat, saturated fat, sugars, salt, protein, and fiber (per 100 g, or per 100 ml for liquids), and `completeness`; missing values stay absent, never imputed
72
+ - Rows carry `found`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, energy, fat, saturated fat, carbohydrates, sugars, salt, protein, and fiber (per 100 g, or per 100 ml for liquids), and `completeness`; missing values stay absent, never imputed
73
+ - A read of prepared-product figures adds a `prepared` object on the row with the same columns, read from the `*_prepared_100g` keys and never substituted for an as-sold figure. A row with as-sold figures appears in the as-sold table, a row with `prepared` in the "as prepared" table, and a row with both appears in both
65
74
  - `not_found` lists barcodes with no contributor record and `failed` lists fetches that failed, each with a `reason`; a failed barcode gets no row and never blocks the rest
66
75
 
67
76
  ---
68
77
 
69
78
  ### `off_browse_taxonomy` <sub>tool</sub>
70
79
 
71
- - `facet` is one of `categories`, `labels`, `allergens`, `additives`, `countries`, `nova_groups`, `nutrition_grades`; `search` matches a substring of the tag ID, name, or a synonym ("shellfish" → `en:crustaceans`); `limit` (default 20) returns up to 100 per call (a value up to 200 still returns 100); a cut result says how many matched; no paging
80
+ - `facet` is one of `categories`, `labels`, `allergens`, `additives`, `countries`, `nova_groups`, `nutrition_grades`; `search` matches a substring of the tag ID, name, or a synonym ("shellfish" → `en:crustaceans`); `limit` (default 20) returns up to 200 per call, with no input maximum (a larger value returns the first 200); a cut result says how many matched; no paging. Each lookup asks for up to 2,000 suggestions, above the largest answer measured (1,313), so an E-number term such as `e314` resolves to `en:e314` and the match count is exact below that size
72
81
  - Returns `tags[]` of `id` and `name`, where `id` goes to `off_search_products` unchanged; `nova_groups` and `nutrition_grades` come back complete with `total_in_facet`
73
82
  - The five open facets resolve against the live Open Food Facts taxonomy and fall back to a small offline sample, with a `notice`, when it is unavailable; omitting `search` returns only that sample
74
83
 
@@ -79,151 +88,46 @@ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): s
79
88
  Open Food Facts-specific:
80
89
 
81
90
  - No API key; the identifying `User-Agent` Open Food Facts' terms require is built in
82
- - Per-endpoint request budgets (product 15/min, search 10/min, taxonomy 10/min); transient failures retry up to 4 attempts
91
+ - Per-endpoint request budgets (product 15/min, search 10/min, taxonomy 10/min); transient failures retry up to 4 attempts within a total deadline of 35 s for a product or search read and 20 s for a taxonomy lookup (one caller's search that canonicalizes a tag first can spend 55 s), after which the read fails as `upstream_timeout`, or `rate_limited` when its last finished attempt answered 429
83
92
  - Nutriments normalized from hyphenated keys (`energy-kcal_100g` → `energy_kcal_100g`), with `additional_100g` / `additional_serving` maps that label each nutrient with its unit
84
93
  - Tag filters take canonical IDs (`en:organic`, `en:no-gluten`); on text searches a case variant, synonym, or brand name is canonicalized first (`US` → `en:united-states`)
85
- - Every field is contributor-entered, so a missing one means not yet recorded; Nutri-Score, NOVA, and Green-Score are Open Food Facts' own computed grades, returned as-is, and carry regional formula caveats
94
+ - Every field is contributor-entered, so a missing one means not yet recorded, except that a read of prepared-product nutrition omits as-sold figures the record can still hold; Nutri-Score, NOVA, and Green-Score are Open Food Facts' own computed grades, returned as-is, and carry regional formula caveats
86
95
 
87
96
  Agent-friendly output:
88
97
 
89
98
  - Per-serving nutrition carries its denominator: `serving_size`, `serving_quantity`, `serving_quantity_unit`, and a note when none is recorded
90
99
  - Graceful partial failure: `off_compare_products` returns resolved rows alongside `not_found` and `failed`
91
- - Explicit count bounds: `total_is_lower_bound`, `last_page`, and `omitted` on searches
92
- - Typed failures: each carries a declared `reason` (`upstream_error`, `upstream_timeout`, `upstream_rejected`, `rate_limited`, plus per-tool input reasons) and a recovery hint; an incomplete upstream response is a retryable `upstream_error`, never an empty result or a not-found
100
+ - Explicit count bounds: `total_is_lower_bound`, `last_page`, `omitted`, and `outside_constraints` on searches
101
+ - Typed failures: each carries a declared `reason` (`upstream_error`, `upstream_timeout`, `upstream_rejected`, `rate_limited`, plus per-tool input reasons) and a recovery hint; an incomplete upstream response is a retryable `upstream_error`, or `upstream_timeout` when search flags a timeout, never an empty result or a not-found
93
102
 
94
103
  ## Getting started
95
104
 
96
- ### Public Hosted Instance
97
-
98
- A public instance is available at `https://openfoodfacts.caseyjhand.com/mcp` — no installation required. It serves Streamable HTTP; each client takes its own config shape.
99
-
100
- For Claude Code:
105
+ Connect to the hosted instance, no install:
101
106
 
102
107
  ```sh
103
108
  claude mcp add --transport http openfoodfacts-mcp-server https://openfoodfacts.caseyjhand.com/mcp
104
109
  ```
105
110
 
106
- For Cline (`cline_mcp_settings.json`):
107
-
108
- ```json
109
- {
110
- "mcpServers": {
111
- "openfoodfacts-mcp-server": {
112
- "type": "streamableHttp",
113
- "url": "https://openfoodfacts.caseyjhand.com/mcp"
114
- }
115
- }
116
- }
117
- ```
118
-
119
- For Cursor (`mcp.json`):
120
-
121
- ```json
122
- {
123
- "mcpServers": {
124
- "openfoodfacts-mcp-server": {
125
- "url": "https://openfoodfacts.caseyjhand.com/mcp"
126
- }
127
- }
128
- }
129
- ```
130
-
131
- Claude Desktop adds remote servers under Customize → Connectors.
132
-
133
- ### Self-Hosted / Local
111
+ Or run it locally:
134
112
 
135
- No API key is required. Add the following to your MCP client configuration file.
136
-
137
- ```json
138
- {
139
- "mcpServers": {
140
- "openfoodfacts-mcp-server": {
141
- "type": "stdio",
142
- "command": "bunx",
143
- "args": ["@cyanheads/openfoodfacts-mcp-server@latest"],
144
- "env": {
145
- "MCP_TRANSPORT_TYPE": "stdio",
146
- "MCP_LOG_LEVEL": "info"
147
- }
148
- }
149
- }
150
- }
113
+ ```sh
114
+ claude mcp add openfoodfacts-mcp-server -- npx -y @cyanheads/openfoodfacts-mcp-server
151
115
  ```
152
116
 
153
- Or with npx (no Bun required):
117
+ For other clients, add this to the MCP config file:
154
118
 
155
119
  ```json
156
120
  {
157
121
  "mcpServers": {
158
122
  "openfoodfacts-mcp-server": {
159
- "type": "stdio",
160
123
  "command": "npx",
161
- "args": ["-y", "@cyanheads/openfoodfacts-mcp-server@latest"],
162
- "env": {
163
- "MCP_TRANSPORT_TYPE": "stdio",
164
- "MCP_LOG_LEVEL": "info"
165
- }
124
+ "args": ["-y", "@cyanheads/openfoodfacts-mcp-server"]
166
125
  }
167
126
  }
168
127
  }
169
128
  ```
170
129
 
171
- Or with Docker:
172
-
173
- ```json
174
- {
175
- "mcpServers": {
176
- "openfoodfacts-mcp-server": {
177
- "type": "stdio",
178
- "command": "docker",
179
- "args": [
180
- "run", "-i", "--rm",
181
- "-e", "MCP_TRANSPORT_TYPE=stdio",
182
- "ghcr.io/cyanheads/openfoodfacts-mcp-server:latest"
183
- ]
184
- }
185
- }
186
- }
187
- ```
188
-
189
- For Streamable HTTP, set the transport and start the server:
190
-
191
- ```sh
192
- MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
193
- # Server listens at http://localhost:3010/mcp
194
- ```
195
-
196
- ### Prerequisites
197
-
198
- - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
199
- - No API key or account. Open Food Facts asks clients to identify themselves, and the server's `User-Agent` does so without configuration.
200
-
201
- ### Installation
202
-
203
- 1. **Clone the repository:**
204
-
205
- ```sh
206
- git clone https://github.com/cyanheads/openfoodfacts-mcp-server.git
207
- ```
208
-
209
- 2. **Navigate into the directory:**
210
-
211
- ```sh
212
- cd openfoodfacts-mcp-server
213
- ```
214
-
215
- 3. **Install dependencies:**
216
-
217
- ```sh
218
- bun install
219
- ```
220
-
221
- 4. **Configure environment:**
222
-
223
- ```sh
224
- cp .env.example .env
225
- # edit .env if you need to override rate limits or the base URL
226
- ```
130
+ No API key or account is needed: the server identifies itself to Open Food Facts with its own `User-Agent`. For Docker or a source build, see [Running the server](#running-the-server).
227
131
 
228
132
  ## Configuration
229
133
 
@@ -246,6 +150,16 @@ See [`.env.example`](./.env.example) for the full list of optional overrides.
246
150
 
247
151
  ### Local development
248
152
 
153
+ Requires [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
154
+
155
+ - **Clone and install:**
156
+
157
+ ```sh
158
+ git clone https://github.com/cyanheads/openfoodfacts-mcp-server.git
159
+ cd openfoodfacts-mcp-server && bun install
160
+ cp .env.example .env # optional: override rate limits or the base URL
161
+ ```
162
+
249
163
  - **Build and run:**
250
164
 
251
165
  ```sh
@@ -269,22 +183,22 @@ See [`.env.example`](./.env.example) for the full list of optional overrides.
269
183
  ### Docker
270
184
 
271
185
  ```sh
272
- docker build -t openfoodfacts-mcp-server .
273
- docker run --rm -p 3010:3010 openfoodfacts-mcp-server
186
+ docker run --rm -p 3010:3010 ghcr.io/cyanheads/openfoodfacts-mcp-server:latest
274
187
  ```
275
188
 
276
- The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/openfoodfacts-mcp-server`. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
189
+ The image defaults to HTTP transport, stateless session mode, and logs to `/var/log/openfoodfacts-mcp-server`. To run it over stdio from an MCP client, use `"command": "docker"` with `"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/openfoodfacts-mcp-server:latest"]`. To build locally, `docker build -t openfoodfacts-mcp-server .`; OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
277
190
 
278
191
  ## Project structure
279
192
 
280
193
  | Directory | Purpose |
281
194
  |:----------|:--------|
282
- | `src/index.ts` | `createApp()` entry point — registers tools and inits services. |
195
+ | `src/index.ts` | `createApp()` entry point — registers tools and the published skills, and inits services. |
283
196
  | `src/config` | Server-specific environment variable parsing and validation with Zod. |
284
197
  | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`). |
285
198
  | `src/services/openfoodfacts` | Open Food Facts API client — HTTP, rate limiting, retry, field normalization. |
286
199
  | `src/services/taxonomy` | Tag vocabulary — live resolution, offline sample, and tag-value canonicalization. |
287
200
  | `src/utils` | Markdown escaping for contributor-entered values in text output. |
201
+ | `skills/` | Published Agent Skills for the agents that call the tools. |
288
202
  | `tests/` | Unit and integration tests mirroring `src/`. |
289
203
 
290
204
  ## Development guide
@@ -0,0 +1,30 @@
1
+ ---
2
+ summary: "Publishes the openfoodfacts-guide Agent Skill over MCP, renders search notices as block quotes with escaped caller text, and moves to mcp-ts-core 0.13.18 (MCP SDK 2.4; a 0.13.16 HTTP per-request memory leak fixed)."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.4.0 — 2026-10-28
8
+
9
+ ## Added
10
+
11
+ - **`skill://openfoodfacts-guide/SKILL.md`** served over MCP through `createApp({ skills: 'skills' })`: allergy checks that never overclaim, choosing among a product's barcodes, tag-ID sources, grade ranges, free-from searches, and the shared search budget. `resources/list` gains the skill and the `skill://{+path}` template, and `initialize` advertises the `io.modelcontextprotocol/skills` extension; `tools/list` is unchanged. The skill also ships in the npm package, Docker image, plugin installs, and `.mcpb` bundle.
12
+ - **Root `plugin.json` and `mcp.json`**: the Agent Plugins 1.0.0 pair, alongside the existing `.claude-plugin/` and `.codex-plugin/` manifests.
13
+
14
+ ## Changed
15
+
16
+ - **README** Getting started is shorter (hosted `claude mcp add`, local `npx`, one generic config), and a Skills section lists `openfoodfacts-guide`.
17
+ - **Maintenance** re-syncs framework skills (adds `add-skill`) and scripts, anchors every `.mcpbignore` path to the repo root, copies `skills/` in the Dockerfile, and merges the issue-template environment fields into one table.
18
+
19
+ ## Fixed
20
+
21
+ - **`off_search_products` notices** ([#65](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/65)): under mcp-ts-core 0.13.18, which renders a generic enrichment string as a code span, the zero-result, past-the-end, and all-omitted notices rendered in `content[]` as code spans. They now go through `ctx.enrich.notice()` and render as block quotes, like the taxonomy tool's.
22
+ - **Notices are Markdown-escaped where they render**: caller and upstream values echoed in `off_search_products` and `off_browse_taxonomy` notices (query, tag filters, taxonomy term, upstream failure cause) are escaped in the `content[]` block quote, so they can no longer reshape it; `structuredContent.notice` carries them as sent.
23
+
24
+ ## Dependencies
25
+
26
+ - **`@cyanheads/mcp-ts-core`** ^0.13.16 → ^0.13.18: MCP SDK ^2.3.1 → ^2.4.0 and the HTTP transport no longer retaining ~71 KiB per request, a leak in 0.3.9's framework 0.13.16 (both 0.13.17); published skills served over MCP and `.mcpb` bundles that drop dev paths and build-only files (0.13.18).
27
+ - **`@biomejs/biome`** ^2.6.2 → ^2.6.3.
28
+ - **`ignore`** ^7.0.13 → ^7.0.14.
29
+ - **`tsc-alias`** ^1.9.8 → ^1.9.9.
30
+ - **`bunfig.toml`** `minimumReleaseAge` 3 days → 1 day.
@@ -0,0 +1,30 @@
1
+ ---
2
+ summary: "Checks every nutrient-filtered row against the caller bounds, caps each read with a total retry deadline, treats a flagged search timeout and a dropped connection as declared failures, labels prepared figures on product reads, adds prepared and carbohydrate figures to comparisons, and returns up to 200 taxonomy tags."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.4.1 — 2026-10-30
8
+
9
+ ## Changed
10
+
11
+ - **`off_get_product` prepared figures** ([#59](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/59)): Open Food Facts returns one preparation state of the nutrition per read, so a read of prepared-product figures carries `_prepared` keys and no named as-sold field. The text now prints those keys in "as prepared" sections and states that the read returned only prepared figures, not that the record holds no as-sold ones; `structuredContent` values are unchanged and the field descriptions name `_prepared` keys.
12
+ - **`off_compare_products` prepared figures** ([#59](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/59)): a row gains an optional `prepared` object with the same columns, read from `*_prepared_100g` keys and never substituted for an as-sold figure. The text adds an "as prepared" table, and a row with prepared figures only leaves the as-sold table. As-sold figures are not read for prepared products yet ([#73](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/73)).
13
+ - **`off_compare_products` carbohydrates** ([#71](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/71)): rows gain an optional `carbohydrates_100g`, and the text table a Carbohydrates column between saturated fat and sugars.
14
+ - **`off_browse_taxonomy` returns up to 200 tags** ([#70](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/70)), up from 100. `limit` has no maximum: a value above 200 returns the first 200 and, when more matched, says how many did, instead of failing validation. Each lookup now asks for up to 2,000 suggestions, above the largest answer measured (1,313), so the match count is exact below that size; an answer that reached it would be cut with nothing flagging it.
15
+ - **`off_search_products` `page_size`** ([#70](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/70)) has no maximum: a value over 10,000, previously rejected, is served at 50 and reported in `page_size_capped`.
16
+ - **Retry deadlines** ([#72](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/72)): all attempts and backoff of one read share a deadline, 35 s for a product or search read and 20 s for a taxonomy lookup, and the request in flight is cut off at expiry. A read that would have finished later now fails as the retryable `upstream_timeout`, or as `rate_limited` when its last finished attempt answered 429. A taxonomy lookup that expires does not fail its tool by itself: `off_browse_taxonomy` falls back to its offline sample with a notice, a tag filter is sent normalized, and an exclusion ends as the retryable `unrecognized_exclusion`. A search that canonicalizes a tag first can spend 55 s for one caller; one joined to a lookup whose first caller cancels can run longer ([#75](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/75)).
17
+ - **Docs and skill**: tool descriptions, the server instructions, the `openfoodfacts-guide` skill, and the README describe the row check, the deadlines, and prepared figures.
18
+
19
+ ## Fixed
20
+
21
+ - **`off_search_products` `nutrient_filters`** ([#64](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/64)): the text index compares `energy-kcal`, `fat`, `saturated-fat`, `carbohydrates`, and `sugars` as whole numbers, so `sugars lte 3` returned a row at 3.53 and `sugars gt 3` dropped matches. `gt` and `gte` on those five now widen to the whole number below, and every returned row is checked against every constraint at the index's 32-bit precision. Rows 0.4.0 returned outside the constraints are gone.
22
+ - **`outside_constraints`** ([#64](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/64)): new optional count of the rows left off a page by that check. A page can come back short or empty while later pages hold matches, and `total`, `total_is_lower_bound`, and `last_page` still describe the index's whole-number answer, which the text now says.
23
+ - **Flagged search timeouts** ([#68](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/68)): a text-search or autocomplete answer carrying `timed_out: true` was read as complete, reporting a partial page as exact and caching an empty autocomplete as `no_match`. The read now fails as the retryable `upstream_timeout`. A text search reports that reason, which `off_search_products` declares for it. A failed autocomplete is not cached: `off_browse_taxonomy` falls back to its offline sample with a notice, a tag filter is sent normalized, and an exclusion ends as the retryable `unrecognized_exclusion`.
24
+ - **`off_get_product` `serving_quantity`** ([#69](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/69)): requesting `serving_quantity` now also fetches `serving_quantity_unit`, so a unit the record holds is no longer reported as not recorded. A record with no unit still reads "unit not recorded".
25
+ - **Dropped connections** ([#74](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/74)): `off_get_product` and `off_search_products` failed with no `reason` when Open Food Facts closed the connection mid-body. They now fail as the retryable `upstream_error`. `off_compare_products` already listed such a barcode in `failed` as `upstream_error`, and `off_browse_taxonomy` already fell back to its offline sample with a notice; both now name the dropped connection instead of the runtime's message.
26
+
27
+ ## Dependencies
28
+
29
+ - **`@biomejs/biome`** ^2.6.3 → ^2.6.4 (dev).
30
+ - **`@types/node`** ^26.6.5 → ^26.6.6 (dev).
package/dist/index.js CHANGED
@@ -11,7 +11,8 @@ await createApp({
11
11
  name: 'openfoodfacts-mcp-server',
12
12
  title: 'openfoodfacts-mcp-server',
13
13
  tools: allToolDefinitions,
14
- instructions: 'Query Open Food Facts, a free crowd-sourced database of 3M+ packaged foods: off_get_product looks up one product by barcode (4–40 digits), off_search_products finds products by text or tag filters, off_compare_products puts several side by side on nutrition, and off_browse_taxonomy resolves a human term to the canonical tag ID the other tools filter on — so start there when you have a category or label rather than a barcode. Every field is contributor-entered, so a missing one means "not yet recorded" rather than absent, and the computed Nutri-Score, NOVA, and Green-Score grades carry regional caveats. No API key is required; the data is ODbL 1.0, so cite Open Food Facts wherever you pass it on.',
14
+ skills: 'skills',
15
+ instructions: 'Query Open Food Facts, a free crowd-sourced database of 3M+ packaged foods: off_get_product looks up one product by barcode (4–40 digits), off_search_products finds products by text or tag filters, off_compare_products puts several side by side on nutrition, and off_browse_taxonomy resolves a human term to the canonical tag ID the other tools filter on — so start there when you have a category or label rather than a barcode. Every field is contributor-entered, so a missing one means "not yet recorded" rather than absent, except that a read of prepared-product nutrition omits as-sold figures the record can still hold, and the computed Nutri-Score, NOVA, and Green-Score grades carry regional caveats. No API key is required; the data is ODbL 1.0, so cite Open Food Facts wherever you pass it on.',
15
16
  sessionMode: 'stateless',
16
17
  setup() {
17
18
  initOpenFoodFactsService();
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,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,wBAAwB,EAAE,MAAM,mDAAmD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,0BAA0B;IAChC,KAAK,EAAE,0BAA0B;IACjC,KAAK,EAAE,kBAAkB;IACzB,YAAY,EACV,ksBAAksB;IACpsB,WAAW,EAAE,WAAW;IACxB,KAAK;QACH,wBAAwB,EAAE,CAAC;QAC3B,mBAAmB,EAAE,CAAC;IACxB,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,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,wBAAwB,EAAE,MAAM,mDAAmD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,0BAA0B;IAChC,KAAK,EAAE,0BAA0B;IACjC,KAAK,EAAE,kBAAkB;IACzB,MAAM,EAAE,QAAQ;IAChB,YAAY,EACV,oyBAAoyB;IACtyB,WAAW,EAAE,WAAW;IACxB,KAAK;QACH,wBAAwB,EAAE,CAAC;QAC3B,mBAAmB,EAAE,CAAC;IACxB,CAAC;CACF,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"browse-taxonomy.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAWjD,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;EAiKhC,CAAC"}
1
+ {"version":3,"file":"browse-taxonomy.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAUjD,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;EA6KhC,CAAC"}
@@ -3,14 +3,13 @@
3
3
  * @module mcp-server/tools/definitions/browse-taxonomy
4
4
  */
5
5
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
- import { MAX_AUTOCOMPLETE_SIZE } from '../../../services/openfoodfacts/openfoodfacts-service.js';
7
6
  import { getTaxonomyService } from '../../../services/taxonomy/taxonomy-service.js';
8
7
  import { mdInline, mdInlineCode } from '../../../utils/markdown.js';
9
8
  /**
10
- * Most tags one call returns. A larger `limit`, up to the suggestions one live lookup reads
11
- * (`MAX_AUTOCOMPLETE_SIZE`), returns this many and discloses the cut when more matched.
9
+ * Most tags one call returns. A larger `limit` returns this many and discloses the cut when more
10
+ * matched.
12
11
  */
13
- const TAXONOMY_LIMIT_CAP = 100;
12
+ const TAXONOMY_LIMIT_CAP = 200;
14
13
  export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
15
14
  title: 'Browse Food Facts Taxonomy',
16
15
  description: 'Resolve a human term to the canonical Open Food Facts tag ID that off_search_products filters on. Covers categories, labels/certifications, allergens, additives, countries, NOVA groups, and Nutri-Score grades. Pass a search term to resolve against the Open Food Facts vocabulary, which holds tens of thousands of tags; omitting it returns only a small reference list for each facet except NOVA groups and Nutri-Score grades, which are complete. Most tag IDs use the "en:" prefix (e.g. "en:organic", "en:no-gluten", "en:crustaceans"); NOVA groups return bare digits "1"-"4" and Nutri-Score grades bare letters "a"-"e". Pass the id through to off_search_products exactly as returned. Category tags are frequently plural ("kombucha" resolves to "en:kombuchas"), so use the returned id rather than constructing one.',
@@ -35,13 +34,19 @@ export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
35
34
  .string()
36
35
  .optional()
37
36
  .describe('Term to resolve. Matched case-insensitively as a substring of the tag ID, the display name, or a common synonym of either ("shellfish" resolves to en:crustaceans, "gluten free" to en:no-gluten). A single word works best ("hummus", not "hummus dip"). Omit only to see a small reference list — Open Food Facts cannot list the full vocabulary without a term, so an unfiltered call is not a view of the full facet.'),
37
+ // `.int()` would advertise a `maximum` of 2^53 - 1 for a value the handler clamps to the cap.
38
38
  limit: z
39
39
  .number()
40
- .int()
40
+ .multipleOf(1, 'Send limit as a whole number, not a fractional one.')
41
+ // multipleOf tolerates float noise (20.000000000000004). It stays for what it advertises; this
42
+ // closes the gap, only where it passed, so 1.5 reports once.
43
+ .refine(Number.isInteger, {
44
+ error: 'Send limit as a whole number, not a fractional one.',
45
+ when: ({ issues }) => issues.length === 0,
46
+ })
41
47
  .min(1)
42
- .max(MAX_AUTOCOMPLETE_SIZE)
43
48
  .default(20)
44
- .describe(`Maximum entries to return (default 20). One call returns at most ${TAXONOMY_LIMIT_CAP}: a larger value, up to ${MAX_AUTOCOMPLETE_SIZE} (the most suggestions one lookup reads), returns the first ${TAXONOMY_LIMIT_CAP} and, when more matched, says how many did. There is no offset or page input: Open Food Facts offers no cursor for this lookup, so narrow the search term rather than paging. The tag spelling the term itself (e.g. "lentil" → en:lentils) is listed first among the live matches, so it is not the one a small limit cuts.`),
49
+ .describe(`Maximum entries to return (default 20). One call returns at most ${TAXONOMY_LIMIT_CAP}: a larger value returns the first ${TAXONOMY_LIMIT_CAP} and, when more matched, says how many did. There is no offset or page input: Open Food Facts offers no cursor for this lookup, so narrow the search term rather than paging. The tag spelling the term itself (e.g. "lentil" → en:lentils) is listed first among the live matches, so it is not the one a small limit cuts.`),
45
50
  }),
46
51
  output: z.object({
47
52
  facet: z.string().describe('The facet name that was queried (echoes the input).'),
@@ -72,6 +77,11 @@ export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
72
77
  .optional()
73
78
  .describe(`The limit that was applied — at most ${TAXONOMY_LIMIT_CAP}.`),
74
79
  },
80
+ // The notice echoes the search term and an upstream failure cause, so it is escaped here at render
81
+ // time: structuredContent keeps those values as sent.
82
+ enrichmentTrailer: {
83
+ notice: { render: (v) => `> ${mdInline(String(v))}` },
84
+ },
75
85
  async handler(input, ctx) {
76
86
  const svc = getTaxonomyService();
77
87
  const limit = Math.min(input.limit, TAXONOMY_LIMIT_CAP);