Koskamo

openfoodfacts-mcp-server

An MCP server providing access to the Open Food Facts crowd-sourced database of over 3 million products. It enables agents to fetch detailed product information by barcode, search for items using text queries or structured filters like allergens and nutrition, compare multiple products side-by-side, and browse the canonical tag vocabulary for precise filtering.

Endpoint and access

No sign-in to connect and list tools. Individual tools may still need a vendor account, credentials, or permissions; check the documented prerequisites.

openfoodfacts-mcp-server logo, by cyanheadsCommunity listingStreamable HTTPNo sign-inEndpoint checked 2026-11-06Published by cyanheads
MCP endpoint (Streamable HTTP)
https://openfoodfacts.caseyjhand.com/mcp
Vendor documentation

What its tools do

4 tools, grouped by the hints the server advertises. Hints are self-declared, not verified behavior.

4 of 4 have a description · 4 of 4 declare behavior hints · 4 of 4 with parameters document them all

Checks and signals

Each line is separate evidence, not a trust score.

  • Passed Endpoint answered the live check ·
  • Availability 1 of 1 checks passed in 30 days

What you can do with it

  • Fetch product details like ingredients and allergens by barcode
  • Search for products by keyword, tag, or nutrition thresholds
  • Compare up to 10 products side-by-side on nutrition scores
  • Resolve human terms to canonical tag IDs for filtering

Source and maintenance

Community listingRegistry 0.4.3
Maintenance checked

Protocol and capabilities

Server
openfoodfacts-mcp-server 0.4.3
MCP protocol negotiated in check
2025-11-25
Tools
4 advertised
Resources
1 advertised
Resource templates
1 advertised
Prompts
0 advertised
Server-advertised capabilities
{
  "tools": {
    "listChanged": true
  },
  "logging": {},
  "prompts": {
    "listChanged": true
  },
  "resources": {
    "subscribe": true,
    "listChanged": true
  },
  "extensions": {
    "io.modelcontextprotocol/skills": {
      "directoryRead": true
    }
  }
}

Server-provided instructions

Untrusted server output shown for inspection. It is not Koskamo guidance and is excluded from knowledge search.

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 product read returning only prepared-product nutrition is followed by a read of the as-sold figures the record stores, reported as stored_as_sold, 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.

Tools

4 advertised

off_get_product — Get Food Product by Barcode
Advertised by the server during the last successful anonymous check.
Advertised read-onlyOpen-world

Fetch a packaged food product by barcode (4–40 digits: EAN-13, EAN-8, UPC, and the shorter and longer codes Open Food Facts also holds) from Open Food Facts. Returns the product name, brand, quantity, ingredients (raw text and parsed list), declared allergens, trace allergens the label warns about, additives, the product-level vegan/vegetarian/palm-oil analysis, computed scores (Nutri-Score a–e, NOVA 1–4, Green-Score), nutrition per serving and per 100 g, or per 100 ml for liquids, categories, labels, packaging, origins, countries of sale, image URL, and data completeness. Open Food Facts is a crowd-sourced database — a missing field means "not yet entered by contributors," not that the attribute is absent from the actual product. Nutrition is the exception: Open Food Facts returns one preparation state per read, so a read of prepared figures (_prepared keys under nutriments) omits as-sold figures the record can still hold; when that happens, one more product read fetches the as-sold set the record stores into the as-sold fields, and nutriments.stored_as_sold says whether it was read, the record stores none, or it could not be read. Computed scores carry regional formula caveats and are indicators, not absolute rankings. Data is under ODbL 1.0 — cite Open Food Facts in downstream use.

off_get_product input schema
{
  "type": "object",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "required": [
    "barcode"
  ],
  "properties": {
    "fields": {
      "type": "array",
      "items": {
        "enum": [
          "product_name",
          "brands",
          "quantity",
          "ingredients_text",
          "ingredients",
          "allergens_tags",
          "traces_tags",
          "additives_tags",
          "ingredients_analysis_tags",
          "nutriscore_grade",
          "nova_group",
          "ecoscore_grade",
          "nutriments",
          "serving_size",
          "serving_quantity",
          "serving_quantity_unit",
          "categories_tags",
          "labels_tags",
          "packaging_tags",
          "origins_tags",
          "countries_tags",
          "image_url",
          "completeness",
          "data_quality_tags"
        ],
        "type": "string",
        "description": "A specific product field to include in the response."
      },
      "description": "Subset of fields to return. Omitting returns all standard fields. Use to reduce payload when only scores or ingredients are needed. A field that cannot be read on its own arrives with what it depends on: nutriments brings serving_size, serving_quantity, and serving_quantity_unit so per-serving figures carry their denominator, and serving_quantity and serving_quantity_unit each bring the other plus serving_size, since a quantity reads only with its unit. requested_fields echoes the full set that was fetched."
    },
    "barcode": {
      "type": "string",
      "pattern": "^0*[1-9]\\d{3,39}$",
      "description": "Product barcode, digits only: 4–40 digits after any leading zeros. The primary key for Open Food Facts — the barcode of an off_search_products row works as is. Example: \"3017620422003\" (Nutella FR)."
    }
  },
  "additionalProperties": false
}
off_get_product output schema
{
  "type": "object",
  "anyOf": [
    {
      "not": {
        "required": [
          "error"
        ]
      },
      "required": [
        "barcode",
        "product",
        "source"
      ]
    },
    {
      "required": [
        "error"
      ]
    }
  ],
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "integer",
          "maximum": 9007199254740991,
          "minimum": -9007199254740991,
          "description": "JSON-RPC error code for this failure."
        },
        "data": {
          "type": "object",
          "properties": {
            "reason": {
              "type": "string",
              "examples": [
                "not_found",
                "upstream_error",
                "upstream_timeout",
                "upstream_rejected",
                "rate_limited"
              ],
              "description": "Machine-readable failure mode. Declared by this tool: `not_found`: Open Food Facts answers HTTP 404 or status 0 for the barcode — no contributor has recorded it. `upstream_error`: Open Food Facts returns a 5xx other than 501 or 504, serves an HTML error page with a 2xx or 5xx status, answers 2xx with a malformed envelope, closes the connection before finishing its answer, or is unreachable. `upstream_timeout`: Open Food Facts did not answer within the request or retry deadline, or answered 408, 425, or 504. `upstream_rejected`: Open Food Facts refuses the request with a 4xx other than 404, 408, 425, or 429, or with 501 Not Implemented. `rate_limited`: This server's own per-minute product budget is spent, or Open Food Facts answers 429. Other values are possible when a failure originates below the handler."
            },
            "recovery": {
              "type": "object",
              "required": [
                "hint"
              ],
              "properties": {
                "hint": {
                  "type": "string"
                }
              },
              "description": "Actionable next step for the caller.",
              "additionalProperties": {}
            },
            "retryable": {
              "type": "boolean",
              "description": "Whether retrying may succeed."
            }
          },
          "additionalProperties": {}
        },
        "message": {
          "type": "string",
          "description": "Human-readable description of what went wrong."
        }
      },
      "description": "Present when the call failed. Absent on success.",
      "additionalProperties": {}
    },
    "source": {
      "enum": [
        "off_api",
        "off_mirror"
      ],
      "type": "string",
      "description": "Where this product was read: \"off_api\", Open Food Facts itself, or \"off_mirror\", this server's local copy of the Open Food Facts export, which serves a barcode it holds on a deployment that keeps one. The stored as-sold read behind nutriments.stored_as_sold, when one runs, reads the same source."
    },
    "barcode": {
      "type": "string",
      "description": "The input barcode, echoed back unchanged. Open Food Facts can hold the record under another form of the same code (030000010402 resolves to the record stored as 0030000010402); that stored form is not reported."
    },
    "product": {
      "type": "object",
      "properties": {
        "brands": {
          "type": "string",
          "description": "Brand name(s), comma-separated."
        },
        "quantity": {
          "type": "string",
          "description": "Net quantity as printed on packaging (e.g. \"400g\")."
        },
        "image_url": {
          "type": "string",
          "description": "Front image URL (CDN-hosted JPEG)."
        },
        "nova_group": {
          "type": "number",
          "description": "NOVA food processing class (1=unprocessed, 2=culinary ingredients, 3=processed, 4=ultra-processed). Absent when not enough data."
        },
        "nutriments": {
          "type": "object",
          "properties": {
            "fat_100g": {
              "type": "number",
              "description": "Total fat in grams per 100 g, or per 100 ml for liquids."
            },
            "salt_100g": {
              "type": "number",
              "description": "Salt in grams per 100 g, or per 100 ml for liquids."
            },
            "fiber_100g": {
              "type": "number",
              "description": "Dietary fiber in grams per 100 g, or per 100 ml for liquids. Often absent."
            },
            "fat_serving": {
              "type": "number",
              "description": "Total fat per serving in grams. Absent when serving size not defined, and beside figures read from a stored as-sold set, which come per 100 g, or per 100 ml for liquids, only."
            },
            "sodium_100g": {
              "type": "number",
              "description": "Sodium in grams per 100 g, or per 100 ml for liquids."
            },
            "sugars_100g": {
              "type": "number",
              "description": "Total sugars in grams per 100 g, or per 100 ml for liquids."
            },
            "proteins_100g": {
              "type": "number",
              "description": "Protein in grams per 100 g, or per 100 ml for liquids."
            },
            "stored_as_sold": {
              "oneOf": [
                {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "read",
                      "description": "One stored as-sold set was read into the as-sold fields."
                    }
                  },
                  "description": "The as-sold fields hold one stored set, never merged: per 100 g, or per 100 ml for liquids, when the record stores it so, else a serving of at least 5 g or ml scaled to 100 as Open Food Facts scales it, from the manufacturer, packaging, USDA, or another non-estimate source, in that order. Each nutrient is the entered value, or the one Open Food Facts computed when none was entered; IU and % DV figures are left out.",
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "none",
                      "description": "The record stores no as-sold set the as-sold fields can be read from."
                    }
                  },
                  "description": "Open Food Facts stores no entered as-sold figures for it that can be given per 100 g, or per 100 ml for liquids: the record stores no sets, or only prepared ones, ones estimated from the ingredients, servings under 5 g or ml, or figures in units that do not convert. The as-sold fields stay absent because nothing is entered.",
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "required": [
                    "status",
                    "reason",
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string",
                      "description": "What went wrong reading the stored sets and what to do about it."
                    },
                    "reason": {
                      "type": "string",
                      "description": "Declared failure reason — one of upstream_error, upstream_timeout, upstream_rejected, rate_limited."
                    },
                    "status": {
                      "type": "string",
                      "const": "unread",
                      "description": "The stored sets could not be read; the call itself succeeded."
                    },
                    "retryAfter": {
                      "type": "integer",
                      "maximum": 9007199254740991,
                      "minimum": -9007199254740991,
                      "description": "Whole seconds to wait before reading again: the time until this server's own product budget frees a slot, or the Retry-After Open Food Facts sent with a 429. Absent when the failure named no wait."
                    }
                  },
                  "description": "The as-sold figures the record can still hold were not read, so the prepared figures are all this call holds. Never a failed call.",
                  "additionalProperties": false
                }
              ],
              "description": "How the read of the record's stored as-sold figures went for this product. Present only when its product read returned prepared figures and no as-sold one, which is when that second read of the record's stored nutrition sets runs (Open Food Facts serves one preparation state per product read, and prepared wins)."
            },
            "sugars_serving": {
              "type": "number",
              "description": "Sugars per serving in grams. Absent when serving size not defined, and beside figures read from a stored as-sold set, which come per 100 g, or per 100 ml for liquids, only."
            },
            "additional_100g": {
              "type": "object",
              "description": "Every other nutrient this read returned per 100 g, or per 100 ml for liquids, keyed by normalized name (calcium, iron, vitamin_c, trans_fat, added_sugars, cholesterol, energy in kJ, …). Excludes the named fields above, so a nutrient appears in exactly one place. A key ending in _prepared (energy_kcal_prepared, carbohydrates_prepared, …) is a figure for the prepared product, not the product as sold. Micronutrients are in grams, so calcium 0.071 g is 71 mg.",
              "propertyNames": {
                "type": "string",
                "description": "Nutrient name, hyphens normalized to underscores."
              },
              "additionalProperties": {
                "type": "object",
                "required": [
                  "value"
                ],
                "properties": {
                  "unit": {
                    "type": "string",
                    "description": "Unit Open Food Facts normalizes this nutrient to: \"g\" for most nutrients, \"kJ\" for energy, energy_kj, and energy_from_fat, \"kcal\" for energy_kcal, \"% vol\" for alcohol, \"%\" for the fruits-vegetables, collagen, cocoa, whole-grain, moisture, and crude (ash, fat, fibre, protein) ratios, and \"mmol/l\" for water hardness; a _prepared entry takes its base nutrient's unit. Absent for the unitless pH and glycemic index."
                  },
                  "value": {
                    "type": "number",
                    "description": "The figure Open Food Facts holds per 100 g, or per 100 ml for liquids."
                  }
                },
                "description": "One nutrient figure with the unit it is expressed in.",
                "additionalProperties": false
              }
            },
            "energy_kcal_100g": {
              "type": "number",
              "description": "Energy in kcal per 100 g, or per 100 ml for liquids."
            },
            "additional_serving": {
              "type": "object",
              "description": "The same nutrients per serving. Also carries the per-serving figures for macros that have a named _100g field but no named per-serving one (saturated_fat, carbohydrates, fiber, proteins, salt, sodium). Percent, % vol, and unitless figures are concentrations, so they equal the per-100 figure. Check serving_size for the denominator the other figures are measured against. A key ending in _prepared is a prepared-product figure that Open Food Facts scales from the per-100 figure by serving_quantity, which is often a powder's dry portion rather than the prepared serving, so it can describe a fraction of what is consumed.",
              "propertyNames": {
                "type": "string",
                "description": "Nutrient name, hyphens normalized to underscores."
              },
              "additionalProperties": {
                "type": "object",
                "required": [
                  "value"
                ],
                "properties": {
                  "unit": {
                    "type": "string",
                    "description": "Unit Open Food Facts normalizes this nutrient to: \"g\" for most nutrients, \"kJ\" for energy, energy_kj, and energy_from_fat, \"kcal\" for energy_kcal, \"% vol\" for alcohol, \"%\" for the fruits-vegetables, collagen, cocoa, whole-grain, moisture, and crude (ash, fat, fibre, protein) ratios, and \"mmol/l\" for water hardness; a _prepared entry takes its base nutrient's unit. Absent for the unitless pH and glycemic index."
                  },
                  "value": {
                    "type": "number",
                    "description": "The figure Open Food Facts holds per serving."
                  }
                },
                "description": "One nutrient figure with the unit it is expressed in.",
                "additionalProperties": false
              }
            },
            "carbohydrates_100g": {
              "type": "number",
              "description": "Total carbohydrates in grams per 100 g, or per 100 ml for liquids."
            },
            "saturated_fat_100g": {
              "type": "number",
              "description": "Saturated fat in grams per 100 g, or per 100 ml for liquids."
            },
            "energy_kcal_serving": {
              "type": "number",
              "description": "Energy per serving in kcal. Absent when serving size not defined, and beside figures read from a stored as-sold set, which come per 100 g, or per 100 ml for liquids, only."
            }
          },
          "description": "Nutrition figures normalized to underscore keys. The named fields hold as-sold figures, and keys ending in _prepared under additional_100g and additional_serving hold prepared-product figures. Open Food Facts returns one preparation state per read, and prepared wins, so when the product read returns only prepared figures the record's stored as-sold set is read as well and fills the as-sold fields per 100 g, or per 100 ml for liquids; stored_as_sold says how that went. All values may be absent when nutrition data not yet entered.",
          "additionalProperties": false
        },
        "ingredients": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "text"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "Canonical ingredient ID (e.g. \"en:sugar\", \"en:salt\")."
              },
              "text": {
                "type": "string",
                "description": "Ingredient name as it appears in the list."
              },
              "vegan": {
                "type": "string",
                "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
              },
              "vegetarian": {
                "type": "string",
                "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
              },
              "ingredients": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "text"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Canonical ingredient ID (e.g. \"en:sugar\", \"en:salt\")."
                    },
                    "text": {
                      "type": "string",
                      "description": "Ingredient name as it appears in the list."
                    },
                    "vegan": {
                      "type": "string",
                      "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
                    },
                    "vegetarian": {
                      "type": "string",
                      "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
                    },
                    "ingredients": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "text"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Canonical ingredient ID (e.g. \"en:sugar\", \"en:salt\")."
                          },
                          "text": {
                            "type": "string",
                            "description": "Ingredient name as it appears in the list."
                          },
                          "vegan": {
                            "type": "string",
                            "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
                          },
                          "vegetarian": {
                            "type": "string",
                            "description": "\"yes\", \"no\", or \"maybe\" — absent when unknown."
                          },
                          "percent_estimate": {
                            "type": "number",
                            "description": "Estimated share of the whole product, in percent. On a sub-ingredient it is still a share of the whole product, not of its parent — a parent's estimate already includes its sub-ingredients, so summing across levels double-counts."
                          }
                        },
                        "description": "A sub-ingredient at the third level. An entry nested deeper upstream is listed at this level too, directly after the entry it belongs under.",
                        "additionalProperties": false
                      },
                      "description": "Sub-ingredients of this entry. Anything nested deeper upstream is listed here as well, right after the entry it belongs under, so no entry is dropped. Absent when the entry has none."
                    },
                    "percent_estimate": {
                      "type": "number",
                      "description": "Estimated share of the whole product, in percent. On a sub-ingredient it is still a share of the whole product, not of its parent — a parent's estimate already includes its sub-ingredients, so summing across levels double-counts."
                    }
                  },
                  "description": "A sub-ingredient of a top-level entry.",
                  "additionalProperties": false
                },
                "description": "Sub-ingredients of this entry (e.g. the flours under \"cereal\"), in the same entry shape and nested up to two more levels. Absent when the entry has none."
              },
              "percent_estimate": {
                "type": "number",
                "description": "Estimated share of the whole product, in percent. On a sub-ingredient it is still a share of the whole product, not of its parent — a parent's estimate already includes its sub-ingredients, so summing across levels double-counts."
              }
            },
            "description": "A single top-level parsed ingredient entry.",
            "additionalProperties": false
          },
          "description": "Parsed ingredient list, top level in label order, each entry carrying its sub-ingredients. Absent when not yet parsed by contributors."
        },
        "labels_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Canonical label/certification tag ID (e.g. \"en:organic\")."
          },
          "description": "Label/certification tag IDs. Absence means not yet entered."
        },
        "traces_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Canonical allergen tag ID (e.g. \"en:nuts\")."
          },
          "description": "Allergens the label says the product may contain as traces, from cross-contamination warnings. Distinct from allergens_tags, which carries allergens declared in the ingredients. [\"en:none\"] means the label states no traces; an empty array or an absent field means not yet entered, not trace-free. Values resolve through off_browse_taxonomy's allergens facet."
        },
        "completeness": {
          "type": "number",
          "description": "Data completeness score from 0–1. Below 0.5 indicates many fields are missing."
        },
        "origins_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Ingredient origin tag ID (e.g. \"en:france\")."
          },
          "description": "Ingredient origin tag IDs. Frequently empty."
        },
        "product_name": {
          "type": "string",
          "description": "Product name. May be absent if not yet entered by contributors."
        },
        "serving_size": {
          "type": "string",
          "description": "Serving size as printed on the label (e.g. \"28 g\", \"1 can (12 fl oz)\"). The denominator for every per-serving figure. Absent when contributors have not entered one, in which case per-serving values cannot be converted to or from the per-100 values."
        },
        "additives_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "E-number additive tag ID (e.g. \"en:e322\", \"en:e322i\")."
          },
          "description": "E-number additive tag IDs. Absence means not yet entered."
        },
        "allergens_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Canonical allergen tag ID (e.g. \"en:milk\", \"en:gluten\")."
          },
          "description": "Canonical allergen tag IDs. Absence means not yet entered — not that the product is allergen-free."
        },
        "countries_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Canonical country tag ID (e.g. \"en:france\")."
          },
          "description": "Countries where the product is sold — the same values off_search_products accepts as countries_tag. Distinct from origins_tags, which is where the ingredients come from."
        },
        "ecoscore_grade": {
          "type": "string",
          "description": "Green-Score (formerly Eco-Score) environmental impact grade: \"a-plus\" (lowest impact), then \"a\" through \"f\"; \"unknown\" when the data it needs is missing, or \"not-applicable\" for product categories the score does not cover. Highly variable — depends on packaging, origins, and transport data completeness."
        },
        "packaging_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Packaging material tag ID (e.g. \"en:cardboard\")."
          },
          "description": "Packaging material tag IDs. Often absent."
        },
        "categories_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Canonical category tag ID (e.g. \"en:spreads\")."
          },
          "description": "Category tag IDs in canonical form. Use as filter values for off_search_products."
        },
        "ingredients_text": {
          "type": "string",
          "description": "Raw ingredients text from the label, in the source language."
        },
        "nutriscore_grade": {
          "type": "string",
          "description": "Nutri-Score grade, lowercase: \"a\" (highest nutritional quality) through \"e\", \"unknown\" when the nutrition data entered is not enough to compute it, or \"not-applicable\" for product categories the score does not cover. Absent when Open Food Facts sent none. Regional formula variants exist."
        },
        "serving_quantity": {
          "type": "number",
          "description": "Serving size parsed to a number, in serving_quantity_unit. Absent when Open Food Facts could not parse the printed serving size."
        },
        "data_quality_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Crowd-sourced data quality flag (e.g. \"en:nutrition-completed\", \"en:ingredients-completed-at-least-for-one-language\")."
          },
          "description": "Crowd-sourced data quality flags. Absence means not yet checked."
        },
        "serving_quantity_unit": {
          "type": "string",
          "description": "Unit of serving_quantity — usually \"g\" but \"ml\" for liquids, so it is not safe to assume grams. Absent when serving_quantity is absent or Open Food Facts records no unit."
        },
        "ingredients_analysis_tags": {
          "type": "array",
          "items": {
            "type": "string",
            "description": "Analysis verdict tag (e.g. \"en:non-vegan\", \"en:palm-oil-free\")."
          },
          "description": "Product-level vegan, vegetarian, and palm-oil verdicts computed by Open Food Facts from the parsed ingredients. \"maybe-\" and \"-status-unknown\" values mean the ingredients could not settle it (e.g. \"en:maybe-vegan\")."
        }
      },
      "description": "Product data. Always present on a successful call — a barcode with no contributor record raises the not_found error instead of returning an empty result.",
      "additionalProperties": false
    },
    "mirror_as_of": {
      "type": "string",
      "description": "ISO 8601 time up to which the local copy reflects every product contributed or edited on Open Food Facts. Present only when source is \"off_mirror\"."
    },
    "mirror_snapshot": {
      "type": "string",
      "description": "Present only when source is \"off_mirror\". States that the product comes from the local copy as of mirror_as_of, so an edit made to it since then is not reflected."
    },
    "requested_fields": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "A field name from the requested subset."
      },
      "description": "The field subset that was fetched, when the caller passed `fields` — the requested fields plus the ones they depend on, so every field that can appear in `product` is named here. Absent means all standard fields were requested. Sections outside this subset are omitted because they were not requested — not because Open Food Facts lacks the data."
    }
  },
  "additionalProperties": false
}
off_get_product annotations
{
  "readOnlyHint": true,
  "openWorldHint": true,
  "idempotentHint": true
}
off_search_products — Search Food Products
Advertised by the server during the last successful anonymous check.
Advertised read-onlyOpen-world

Search Open Food Facts by full-text query, structured tag filters, or both at once. Returns a summary list with barcodes, product names, brands, Nutri-Score, NOVA group, and categories — enough for triage and selection, not full label data. Use off_get_product on the returned barcodes for complete details. A text query and tag filters combine: every word of the query must match the product name, generic name, categories, labels, or brand, and every filter provided must hold (e.g. query "dark chocolate" with labels_tag "en:organic" and countries_tag "en:france" returns organic chocolate sold in France); numeric nutrient_filters express thresholds per 100 g, or per 100 ml for liquids, such as sugars below 8 g, and combine the same way, and each row then carries the indexed values the thresholds were checked against; additives_tag is the one exception, filtering only on searches carrying neither query nor nutrient_filters. Tag filter values are canonical tag IDs (e.g. "en:organic", "en:no-gluten") — use off_browse_taxonomy to resolve human terms to tag IDs. A case variant, synonym, or singular of a tag is resolved to its canonical ID where Open Food Facts recognizes it; anything else is matched exactly. exclude_allergens and exclude_traces drop products that declare an allergen or a "may contain" trace, but a product with no allergen or trace data entered passes them, so confirm a candidate with off_get_product before relying on it. At least one search parameter is required. The two paths read different indexes: a search carrying query is answered by the text index, a snapshot that lags the live database, while a tag-only search reads the live database and is current, or, where this server keeps a local copy of the Open Food Facts export, can be served from that copy, which source and mirror_as_of then report and whose default order and popularity_key or unique_scans_n sort follow the values stored with each record, so they can differ from a live search, which ranks by its current scan counts — so a recently contributed product can be missing from a text search and present in the same search without query. Data is crowd-sourced; result count reflects contributed products, not all products in the market. Data under ODbL 1.0 — cite Open Food Facts in downstream use.

off_search_products input schema
{
  "type": "object",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "page": {
      "type": "integer",
      "default": 1,
      "maximum": 9007199254740991,
      "minimum": 1,
      "description": "Page number (1-based). Use with page_size to paginate results. Pages are counted at the page_size served, which is at most 50, so repeating one larger page_size pages contiguously (page 2 at page_size 80 is results 51–100). A search by tag filters alone that reads the live database is served through page 10 only, so at page_size 50 it reaches the first 500 matches; where this server keeps a local copy of the export, a tag-only search whose every value it confirms (canonical IDs from off_browse_taxonomy, or a brand slug) is served from that copy through its first 10000 results, so page * page_size (as served) must stay at or below 10000 there. A search carrying query or nutrient_filters serves only the first 10000 results, so page * page_size (as served) must stay at or below 10000. A request past any of these bounds is rejected rather than sent; narrow the filters or change sort_by to bring other products forward."
    },
    "query": {
      "type": "string",
      "description": "Words to find. Every word must match the product name, generic name, categories, labels, or brand — ingredients and quantity are not searched — so put only words the product itself would carry. Punctuation separates words — except an underscore, an apostrophe or period inside a word, and a decimal comma inside a number — so each part of \"milk-chocolate\" or \"salt/pepper\" must match, while \"l'eau\" and \"1,5\" stay one word. Stop words of English, French, Spanish, German, and Italian (\"with\", \"the\", \"de\", \"mit\", …) are not required, and neither is a content word that is a stop word in one of them (such as Spanish \"soy\"), though it still ranks the results. Names are matched in the 31 languages the text index analyzes, so a product named only in French is found by its French name. At most 24 words, counted as runs of letters and digits, so each part of a hyphenated or apostrophe-joined word counts. Example: \"dark chocolate 70%\". Supplying it routes the search to the text index, a snapshot that lags the live Open Food Facts database; drop it to run the same tag filters against the live database or, where this server keeps one, its local copy of the export."
    },
    "sort_by": {
      "enum": [
        "last_modified_t",
        "unique_scans_n",
        "created_t",
        "popularity_key"
      ],
      "type": "string",
      "description": "Sort order, applied on every search. Each value orders newest or highest first: \"unique_scans_n\" surfaces the most-scanned products, \"last_modified_t\" and \"created_t\" the most recently updated and newest records, \"popularity_key\" the most popular. Omitting it leaves text searches relevance-ranked and tag-only searches in the default order. Where this server serves a tag-only search from its local copy of the export (source \"off_mirror\"), the default order and \"popularity_key\" and \"unique_scans_n\" follow the values stored with each record, so they can differ from a live search, which ranks by its current scan counts; \"last_modified_t\" and \"created_t\" sort alike."
    },
    "page_size": {
      "type": "number",
      "default": 20,
      "minimum": 1,
      "multipleOf": 1,
      "description": "Results per page (default 20). One call returns at most 50: a larger value is served at 50, reported in page_size and page_size_capped, and page then counts pages of 50. Keep low for initial exploration; increase for comparison workflows."
    },
    "brands_tag": {
      "type": "string",
      "description": "Brand slug (lowercased, hyphenated). Example: \"nutella\", \"kelloggs\". A brand name is slugged the way Open Food Facts slugs it (\"Ben & Jerry's\" → \"ben-jerry-s\") and then matched exactly — a partial or misspelled slug matches nothing rather than falling back to a near match, so put open-ended brand wording in query instead."
    },
    "labels_tag": {
      "anyOf": [
        {
          "type": "string",
          "description": "One canonical label tag ID."
        },
        {
          "type": "array",
          "items": {
            "type": "string",
            "description": "One canonical label tag ID."
          },
          "maxItems": 10,
          "description": "Up to 10 canonical label tag IDs, all of which must apply."
        }
      ],
      "description": "Canonical label/certification tag ID, or an array of up to 10 that must all apply. Example: \"en:organic\", or [\"en:organic\", \"en:fair-trade\"] for products carrying both. Use off_browse_taxonomy with facet=\"labels\"."
    },
    "nova_group": {
      "enum": [
        "1",
        "2",
        "3",
        "4"
      ],
      "type": "string",
      "description": "Filter by NOVA food processing class. \"1\"=unprocessed/minimally processed, \"4\"=ultra-processed. Products without a NOVA score are excluded."
    },
    "traces_tag": {
      "type": "string",
      "description": "Canonical allergen tag ID the label warns the product may contain as a trace (\"may contain nuts\"). Example: \"en:nuts\". Trace tags are allergen tags, so off_browse_taxonomy with facet=\"allergens\" resolves them. Selects products carrying the warning; to leave them out, use exclude_traces."
    },
    "additives_tag": {
      "type": "string",
      "description": "Canonical additive (E-number) tag ID. Example: \"en:e322\", \"en:e330\". Use off_browse_taxonomy with facet=\"additives\". Available only on searches carrying neither query nor nutrient_filters — both route to a backend with no additives field, so combining them is rejected instead of silently returning nothing."
    },
    "allergens_tag": {
      "type": "string",
      "description": "Canonical allergen tag ID. Example: \"en:milk\", \"en:gluten\". Use off_browse_taxonomy with facet=\"allergens\". Selects products that declare this allergen; it cannot select allergen-free products, because a product with no allergen tags may simply have none entered yet. To leave an allergen out, use exclude_allergens."
    },
    "countries_tag": {
      "type": "string",
      "description": "Canonical country tag ID. Example: \"en:france\", \"en:united-states\". Filters to products sold in that country."
    },
    "categories_tag": {
      "type": "string",
      "description": "Canonical category tag ID. Example: \"en:breakfast-cereals\", \"en:cheeses\". Use off_browse_taxonomy with facet=\"categories\" to discover valid values."
    },
    "exclude_traces": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "One canonical allergen tag ID to exclude as a trace, e.g. \"en:nuts\"."
      },
      "maxItems": 14,
      "description": "Allergen tag IDs a product's label must not warn it may contain as traces, all applied. Example: [\"en:nuts\"]. Values are validated like exclude_allergens. A product with no trace data entered passes, so check a candidate with off_get_product before relying on it."
    },
    "nutrition_grade": {
      "enum": [
        "a",
        "b",
        "c",
        "d",
        "e"
      ],
      "type": "string",
      "description": "Filter by Nutri-Score grade. \"a\" is highest nutritional quality, \"e\" is lowest. Products without a score are excluded."
    },
    "nutrient_filters": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "nutrient",
          "operator",
          "value"
        ],
        "properties": {
          "value": {
            "type": "number",
            "minimum": 0,
            "description": "Threshold to compare against: kilocalories for energy, grams for every other nutrient, per 100 g, or per 100 ml for liquids."
          },
          "nutrient": {
            "enum": [
              "energy-kcal",
              "fat",
              "saturated-fat",
              "carbohydrates",
              "sugars",
              "fiber",
              "proteins",
              "salt",
              "sodium"
            ],
            "type": "string",
            "description": "Nutrient to constrain, measured per 100 g, or per 100 ml for liquids. Energy is kilocalories; every other value is grams."
          },
          "operator": {
            "enum": [
              "lt",
              "lte",
              "gt",
              "gte"
            ],
            "type": "string",
            "description": "Comparison against value: \"lt\" below, \"lte\" at or below, \"gt\" above, \"gte\" at or above."
          }
        },
        "description": "One numeric constraint on a nutrient value per 100 g, or per 100 ml for liquids."
      },
      "maxItems": 18,
      "description": "Numeric constraints on nutrient values per 100 g, or per 100 ml for liquids, combined as AND with each other and with every other filter. Pair two entries on the same nutrient to express a range (e.g. sugars gte 2 and sugars lte 8). Served only by the text backend, so supplying one routes the search there even without query — it then reads the lagging text index and is subject to the 10,000-result page window, and additives_tag cannot be combined with it. The text index compares energy-kcal, fat, saturated-fat, carbohydrates, sugars as whole numbers, so every returned row is checked against these constraints: a row outside them leaves the page and counts in outside_constraints, and a nutrient the index holds no value for does not count against a row. A page can come back short or empty while later pages hold matches, and a range inside one whole number (sugars gt 3.9 and lt 4) can pass few rows of the 10,000 reachable, so add query or tag filters to narrow it. Per-serving and prepared-product values are not searchable."
    },
    "exclude_allergens": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "One canonical allergen tag ID to exclude, e.g. \"en:nuts\"."
      },
      "maxItems": 14,
      "description": "Allergen tag IDs a product must not declare, all applied. Example: [\"en:nuts\", \"en:peanuts\"]. Each value must be an allergen tag Open Food Facts recognizes — resolve it with off_browse_taxonomy facet=\"allergens\" — and one it does not recognize is rejected rather than sent, because it would exclude nothing. A product with no allergen data entered passes an exclusion, so check a candidate with off_get_product before relying on it."
    },
    "ingredients_analysis_tag": {
      "enum": [
        "en:palm-oil",
        "en:palm-oil-free",
        "en:may-contain-palm-oil",
        "en:palm-oil-content-unknown",
        "en:vegan",
        "en:maybe-vegan",
        "en:non-vegan",
        "en:vegan-status-unknown",
        "en:vegetarian",
        "en:maybe-vegetarian",
        "en:non-vegetarian",
        "en:vegetarian-status-unknown"
      ],
      "type": "string",
      "description": "Vegan, vegetarian, or palm-oil verdict Open Food Facts computes from the parsed ingredients. Example: \"en:vegan\", \"en:palm-oil-free\". \"en:maybe-vegan\" and \"en:may-contain-palm-oil\" mean the ingredients could not settle it, and the \"-unknown\" values mean no verdict could be computed."
    }
  },
  "additionalProperties": false
}
off_search_products output schema
{
  "type": "object",
  "anyOf": [
    {
      "not": {
        "required": [
          "error"
        ]
      },
      "required": [
        "total",
        "total_is_lower_bound",
        "page",
        "page_size",
        "page_count",
        "source",
        "products"
      ]
    },
    {
      "required": [
        "error"
      ]
    }
  ],
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "cap": {
      "type": "number",
      "description": "The page_size that was applied."
    },
    "page": {
      "type": "number",
      "description": "Current page number (1-based)."
    },
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "integer",
          "maximum": 9007199254740991,
          "minimum": -9007199254740991,
          "description": "JSON-RPC error code for this failure."
        },
        "data": {
          "type": "object",
          "properties": {
            "reason": {
              "type": "string",
              "examples": [
                "no_filters",
                "unrecognized_exclusion",
                "additives_filter_needs_tag_search",
                "query_too_long",
                "page_out_of_range",
                "upstream_error",
                "upstream_timeout",
                "upstream_rejected",
                "rate_limited"
              ],
              "description": "Machine-readable failure mode. Declared by this tool: `no_filters`: No search query or filter was provided. `unrecognized_exclusion`: An exclude_allergens or exclude_traces value is not an allergen tag the Open Food Facts vocabulary confirms, or could not be checked because the vocabulary could not be reached or gave no usable answer, Open Food Facts rate-limited the lookup, or this server's own taxonomy budget was spent — an unrecognized exclusion would exclude nothing. `additives_filter_needs_tag_search`: additives_tag was combined with a query or nutrient_filters, which route to a backend that cannot filter by additive. `query_too_long`: query carries more than 24 words, more than the text backend can require at once. `page_out_of_range`: A search by tag filters alone asks for a page past 10 and would read the live database — this server keeps no ready local copy of the export, or a tag value is one it cannot confirm — or a search the text backend serves asks for page * page_size beyond its 10000-result window, or one the local copy serves beyond its 10000-result window, counting page_size at the 50 served when a larger one was requested. `upstream_error`: Open Food Facts returns a 5xx other than 501 or 504, serves an HTML error page with a 2xx or 5xx status, reports a search-engine failure inside an HTTP 200, answers 2xx with a malformed envelope, closes the connection before finishing its answer, or is unreachable. `upstream_timeout`: Open Food Facts did not answer within the request or retry deadline, flagged its text search as timed out, or answered 408, 425, or 504. `upstream_rejected`: Open Food Facts refuses the request with a 4xx other than 408, 425, or 429, or with 501 Not Implemented — the request as formed will be refused again. `rate_limited`: This server's own per-minute search budget is spent, or Open Food Facts answers 429. Other values are possible when a failure originates below the handler."
            },
            "recovery": {
              "type": "object",
              "required": [
                "hint"
              ],
              "properties": {
                "hint": {
                  "type": "string"
                }
              },
              "description": "Actionable next step for the caller.",
              "additionalProperties": {}
            },
            "retryable": {
              "type": "boolean",
              "description": "Whether retrying may succeed."
            }
          },
          "additionalProperties": {}
        },
        "message": {
          "type": "string",
          "description": "Human-readable description of what went wrong."
        }
      },
      "description": "Present when the call failed. Absent on success.",
      "additionalProperties": {}
    },
    "shown": {
      "type": "number",
      "description": "Number of products returned on this page."
    },
    "total": {
      "type": "number",
      "description": "Matching products in the database for this search. Exact unless total_is_lower_bound is true, in which case at least this many match and the real figure is unknown. On a search constraining a nutrient the text index compares as a whole number (see nutrient_filters) this is its whole-number answer, which can include rows those constraints rule out."
    },
    "notice": {
      "type": "string",
      "description": "Guidance about this result set — echoes the filters and suggests how to broaden when nothing matched, names the current page and how far the backend will actually paginate when more results exist, or, on a page that returned no rows because it lies past the last match or every row was left off, says which page to request instead. When rows outside nutrient_filters emptied the page and more rows can be reached or lie past the window, it also suggests adding query or tag filters."
    },
    "source": {
      "enum": [
        "off_api",
        "off_mirror"
      ],
      "type": "string",
      "description": "Where this answer was read: \"off_api\", Open Food Facts itself, or \"off_mirror\", this server's local copy of the Open Food Facts export, which serves a search by tag filters alone on a deployment that keeps one."
    },
    "omitted": {
      "type": "number",
      "description": "Matches on this page left off because Open Food Facts stores them under a code it cannot serve (not 4–40 digits once leading zeros are stripped), so off_get_product could not look them up either. Absent when none was. total still counts them."
    },
    "products": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "barcode"
        ],
        "properties": {
          "brands": {
            "type": "string",
            "description": "Brand name(s), comma-separated. Absent when not yet entered."
          },
          "barcode": {
            "type": "string",
            "description": "Product barcode, 4–40 digits after any leading zeros — a code off_get_product accepts as is, so pass it there for full details. A match stored under a code Open Food Facts cannot serve is left off the page."
          },
          "nova_group": {
            "type": "number",
            "description": "NOVA processing class (1–4). Absent when not assigned."
          },
          "product_name": {
            "type": "string",
            "description": "Product name. May be absent for incompletely entered products."
          },
          "ecoscore_grade": {
            "type": "string",
            "description": "Green-Score environmental impact grade: \"a-plus\" (lowest impact), then \"a\" through \"f\"; \"unknown\" when the data it needs is missing, or \"not-applicable\" for product categories the score does not cover. Absent when Open Food Facts sent none."
          },
          "categories_tags": {
            "type": "array",
            "items": {
              "type": "string",
              "description": "Canonical category tag ID (e.g. \"en:cheeses\")."
            },
            "description": "Category tag IDs in canonical form. Use as filter values for off_search_products."
          },
          "nutriscore_grade": {
            "type": "string",
            "description": "Nutri-Score grade: \"a\" through \"e\", \"unknown\" when the nutrition data entered is not enough to compute it, or \"not-applicable\" for product categories the score does not cover. Absent when Open Food Facts sent none."
          },
          "indexed_nutriments": {
            "type": "object",
            "properties": {
              "fat_100g": {
                "type": "number",
                "description": "Fat in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "salt_100g": {
                "type": "number",
                "description": "Salt in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "fiber_100g": {
                "type": "number",
                "description": "Fiber in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "sodium_100g": {
                "type": "number",
                "description": "Sodium in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "sugars_100g": {
                "type": "number",
                "description": "Sugars in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "proteins_100g": {
                "type": "number",
                "description": "Proteins in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "energy_kcal_100g": {
                "type": "number",
                "description": "Energy in kcal per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "carbohydrates_100g": {
                "type": "number",
                "description": "Carbohydrates in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              },
              "saturated_fat_100g": {
                "type": "number",
                "description": "Saturated fat in g per 100 g, or per 100 ml for liquids, as the text index holds it. Absent when this nutrient was not constrained or the index holds no value for it."
              }
            },
            "description": "Present on every row of a search carrying nutrient_filters, and only there: the values the text index holds for the constrained nutrients, per 100 g, or per 100 ml for liquids — the values the filters were checked against. Keyed as off_get_product keys them (sugars_100g, energy_kcal_100g). A nutrient the index holds no value for is left out, never zero-filled, so the object can be empty, and it does not count against the row. off_get_product returns the current record, whose values can differ.",
            "additionalProperties": false
          }
        },
        "description": "A single matching product summary row.",
        "additionalProperties": false
      },
      "description": "Matching products. Use barcodes with off_get_product for full label data."
    },
    "last_page": {
      "type": "number",
      "description": "Deepest page of this result set that holds products and can be requested, at the page_size served — capped at page 10 on a search by tag filters alone that read the live database, by the 10000-result window on a search the text index answers, and by the local copy's 10000-result window when source is \"off_mirror\". Absent when total_is_lower_bound is true — the total it would divide is the ceiling the backend stopped counting at, so no exact last page exists — and when nothing matched at all. On a search constraining a whole-number nutrient it counts pages of the whole-number answer, so a page up to it can list few products or none."
    },
    "page_size": {
      "type": "number",
      "description": "The page_size served: the one requested, or 50 when a larger one was requested. page and last_page count pages of this size."
    },
    "truncated": {
      "type": "boolean",
      "description": "True when more results exist beyond this page. Absent on the final page of a result set."
    },
    "page_count": {
      "type": "number",
      "description": "Products returned on this page — page_size except on the last page, or when a match stored under a code Open Food Facts cannot serve or a row outside nutrient_filters was left off. Not the total number of pages."
    },
    "mirror_as_of": {
      "type": "string",
      "description": "ISO 8601 time up to which the local copy reflects every product contributed or edited on Open Food Facts. Present only when source is \"off_mirror\"."
    },
    "mirror_snapshot": {
      "type": "string",
      "description": "Present only when source is \"off_mirror\". States that the results come from the local copy as of mirror_as_of, so a product contributed or edited since is missing or shown as it was, and, under the default order or a popularity_key or unique_scans_n sort, that the order follows the values stored with each record and can differ from a live search."
    },
    "page_size_capped": {
      "type": "string",
      "description": "Present only when page_size was above 50: names the page_size requested and the 50 served, and, when a further page holds matches and can be requested, the page to request next and the results it holds."
    },
    "exclusion_coverage": {
      "type": "string",
      "description": "Present only on searches carrying exclude_allergens or exclude_traces. States that products with no allergen or trace data entered pass an exclusion, so a result is not confirmed free of the excluded allergens, and names the off_get_product fields to check."
    },
    "outside_constraints": {
      "type": "number",
      "description": "Rows the text index returned on this page that fail nutrient_filters on their indexed values, compared at the 32-bit precision the index uses, and so left off. The index returns them because it compares some nutrients as whole numbers. Separate from omitted, whose rows are matches; a row both unservable and outside counts only in omitted. total, total_is_lower_bound, and last_page still count these rows, and each page keeps its place in the result set. Absent when none was."
    },
    "text_index_snapshot": {
      "type": "string",
      "description": "Present only on searches the text backend answered. States that those results come from an index snapshot that lags the live Open Food Facts database, so a recently contributed product can be missing from them while the tag-only path still returns it. On a search carrying nutrient_filters it also echoes the constraints and says they were checked against the index values each row carries as indexed_nutriments, which can differ from what off_get_product returns. When a constraint falls on a whole-number nutrient (see nutrient_filters) it also says that total, total_is_lower_bound, and last_page describe the whole-number answer and that rows outside the constraints count in outside_constraints. Absent on tag-only searches, which read the live database or, when source is \"off_mirror\", the local copy mirror_snapshot describes."
    },
    "total_is_lower_bound": {
      "type": "boolean",
      "description": "True when the backend stopped counting at its ceiling and total is a floor, not the match total. Only text searches can hit it; add filters to bring the result set under the ceiling and get an exact count. On a search constraining a whole-number nutrient the floor is on the whole-number answer, not on the products that meet the constraints."
    }
  },
  "additionalProperties": false
}
off_search_products annotations
{
  "readOnlyHint": true,
  "openWorldHint": true,
  "idempotentHint": true
}
off_compare_products — Compare Food Products Side-by-Side
Advertised by the server during the last successful anonymous check.
Advertised read-onlyOpen-world

Side-by-side nutrition and scoring comparison for 2–10 products per call by barcode; a repeated barcode is compared once, so two identical barcodes yield one row, and barcodes past the 10th distinct one are listed in unprocessed rather than fetched. Returns a normalized table of energy (kcal), fat, saturated fat, carbohydrates, sugars, salt, protein, and fiber per 100 g, or per 100 ml for liquids, plus Nutri-Score, NOVA group, and Green-Score. Open Food Facts returns one preparation state per read, so figures for the prepared product arrive in a prepared object on the row, with the same columns and basis, and never fill an as-sold field; a row whose read returned only prepared figures gets one more product read, of the as-sold set its record stores, once the read of every barcode has settled, with stored_as_sold saying how it went. Designed for "which of these cereals is healthiest?" or "compare these pasta brands" workflows. Missing nutrition data for any product is preserved as absent — comparisons are not imputed. A batch is not all-or-nothing: barcodes that resolve are returned even when others fail, with confirmed-missing barcodes listed in not_found and failed fetches listed separately in failed. Scores carry regional formula caveats. Data under ODbL 1.0 — cite Open Food Facts in downstream use.

off_compare_products input schema
{
  "type": "object",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "required": [
    "barcodes"
  ],
  "properties": {
    "barcodes": {
      "type": "array",
      "items": {
        "type": "string",
        "pattern": "^0*[1-9]\\d{3,39}$",
        "description": "Product barcode, digits only: 4–40 digits after any leading zeros."
      },
      "maxItems": 100,
      "minItems": 2,
      "description": "Barcodes to compare, returned as one row each in input order; a repeated barcode is compared once, so two identical barcodes yield one row. One call compares at most 10 distinct barcodes: up to 100 are accepted, and those past the 10th distinct one are returned in unprocessed without being fetched. Example: [\"3017620422003\", \"7622210100146\"]."
    }
  },
  "additionalProperties": false
}
off_compare_products output schema
{
  "type": "object",
  "anyOf": [
    {
      "not": {
        "required": [
          "error"
        ]
      },
      "required": [
        "products",
        "succeeded",
        "not_found"
      ]
    },
    {
      "required": [
        "error"
      ]
    }
  ],
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "integer",
          "maximum": 9007199254740991,
          "minimum": -9007199254740991,
          "description": "JSON-RPC error code for this failure."
        },
        "data": {
          "type": "object",
          "properties": {
            "reason": {
              "type": "string",
              "examples": [
                "upstream_error",
                "upstream_timeout",
                "upstream_rejected",
                "rate_limited"
              ],
              "description": "Machine-readable failure mode. Declared by this tool: `upstream_error`: Open Food Facts returns a 5xx other than 501 or 504, serves an HTML error page with a 2xx or 5xx status, answers 2xx with a malformed envelope, closes the connection before finishing its answer, or is unreachable for a barcode — reported in that barcode's failed entry rather than failing the call. `upstream_timeout`: Open Food Facts did not answer a barcode within the request or retry deadline, or answered 408, 425, or 504 — reported in that barcode's failed entry rather than failing the call. `upstream_rejected`: Open Food Facts refuses a barcode with a 4xx other than 404, 408, 425, or 429, or with 501 Not Implemented — reported in that barcode's failed entry rather than failing the call. `rate_limited`: This server's own per-minute product budget is spent, or Open Food Facts answers 429 — reported in that barcode's failed entry rather than failing the call. Other values are possible when a failure originates below the handler."
            },
            "recovery": {
              "type": "object",
              "required": [
                "hint"
              ],
              "properties": {
                "hint": {
                  "type": "string"
                }
              },
              "description": "Actionable next step for the caller.",
              "additionalProperties": {}
            },
            "retryable": {
              "type": "boolean",
              "description": "Whether retrying may succeed."
            }
          },
          "additionalProperties": {}
        },
        "message": {
          "type": "string",
          "description": "Human-readable description of what went wrong."
        }
      },
      "description": "Present when the call failed. Absent on success.",
      "additionalProperties": {}
    },
    "failed": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "barcode",
          "reason",
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong for this barcode and what to do about it."
          },
          "reason": {
            "type": "string",
            "description": "Declared failure reason — one of upstream_error, upstream_timeout, upstream_rejected, rate_limited."
          },
          "barcode": {
            "type": "string",
            "description": "Barcode whose fetch failed, as provided in input."
          },
          "retryAfter": {
            "type": "integer",
            "maximum": 9007199254740991,
            "minimum": -9007199254740991,
            "description": "Whole seconds to wait before retrying this barcode: the time until this server's own product budget frees a slot, or the Retry-After Open Food Facts sent with a 429. Absent when the failure named no wait."
          }
        },
        "description": "A single barcode whose fetch failed.",
        "additionalProperties": false
      },
      "description": "Barcodes whose fetch failed, with the per-barcode reason. Absent when every fetch completed. A barcode listed here is unknown, not absent from Open Food Facts — retry it with off_get_product before concluding anything about the product. A stored as-sold set that could not be read is not a failed fetch: it stays on its row as stored_as_sold."
    },
    "products": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "barcode",
          "found"
        ],
        "properties": {
          "found": {
            "type": "boolean",
            "description": "False if the barcode has no contributor record."
          },
          "brands": {
            "type": "string",
            "description": "Brand name(s), comma-separated. Absent when not yet entered."
          },
          "source": {
            "enum": [
              "off_api",
              "off_mirror"
            ],
            "type": "string",
            "description": "Where this row was read: \"off_api\", Open Food Facts itself, or \"off_mirror\", this server's local copy of the Open Food Facts export, which serves a barcode it holds on a deployment that keeps one, so one comparison can mix the two. The stored as-sold read behind stored_as_sold, when one runs, reads the same source. Present on every row whose found is true."
          },
          "barcode": {
            "type": "string",
            "description": "Barcode, echoed exactly as provided in input."
          },
          "fat_100g": {
            "type": "number",
            "description": "Total fat in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "prepared": {
            "type": "object",
            "properties": {
              "fat_100g": {
                "type": "number",
                "description": "Total fat in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "salt_100g": {
                "type": "number",
                "description": "Salt in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "fiber_100g": {
                "type": "number",
                "description": "Dietary fiber in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "sugars_100g": {
                "type": "number",
                "description": "Total sugars in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "proteins_100g": {
                "type": "number",
                "description": "Protein in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "energy_kcal_100g": {
                "type": "number",
                "description": "Energy in kcal per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "carbohydrates_100g": {
                "type": "number",
                "description": "Total carbohydrates in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              },
              "saturated_fat_100g": {
                "type": "number",
                "description": "Saturated fat in grams per 100 g, or per 100 ml for liquids, as prepared. Absent when this read returned no such figure."
              }
            },
            "description": "Figures for the prepared product, on the same columns and basis as the as-sold fields, read from Open Food Facts' *_prepared_100g keys and never derived or substituted for an as-sold figure. Open Food Facts returns one preparation state per read, so when the product read returned only prepared figures the record's stored as-sold set is read for the as-sold fields, and stored_as_sold says how that went. Absent when the read carried none of these figures.",
            "additionalProperties": false
          },
          "salt_100g": {
            "type": "number",
            "description": "Salt in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "fiber_100g": {
            "type": "number",
            "description": "Dietary fiber in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "nova_group": {
            "type": "number",
            "description": "NOVA processing class (1–4). Absent when not assigned."
          },
          "sugars_100g": {
            "type": "number",
            "description": "Total sugars in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "completeness": {
            "type": "number",
            "description": "Data completeness 0–1. Low values mean many fields are missing."
          },
          "mirror_as_of": {
            "type": "string",
            "description": "ISO 8601 time up to which the local copy reflects every product contributed or edited on Open Food Facts. Present only when source is \"off_mirror\"."
          },
          "product_name": {
            "type": "string",
            "description": "Product name. Absent when not yet entered by contributors."
          },
          "proteins_100g": {
            "type": "number",
            "description": "Protein in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "ecoscore_grade": {
            "type": "string",
            "description": "Green-Score (formerly Eco-Score) environmental impact grade: \"a-plus\" (lowest impact), then \"a\" through \"f\"; \"unknown\" when the data it needs is missing, or \"not-applicable\" for product categories the score does not cover. Often absent."
          },
          "stored_as_sold": {
            "oneOf": [
              {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "const": "read",
                    "description": "One stored as-sold set was read into the as-sold fields."
                  }
                },
                "description": "The as-sold fields hold one stored set, never merged: per 100 g, or per 100 ml for liquids, when the record stores it so, else a serving of at least 5 g or ml scaled to 100 as Open Food Facts scales it, from the manufacturer, packaging, USDA, or another non-estimate source, in that order. Each nutrient is the entered value, or the one Open Food Facts computed when none was entered; IU and % DV figures are left out.",
                "additionalProperties": false
              },
              {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "const": "none",
                    "description": "The record stores no as-sold set the as-sold fields can be read from."
                  }
                },
                "description": "Open Food Facts stores no entered as-sold figures for it that can be given per 100 g, or per 100 ml for liquids: the record stores no sets, or only prepared ones, ones estimated from the ingredients, servings under 5 g or ml, or figures in units that do not convert. The as-sold fields stay absent because nothing is entered.",
                "additionalProperties": false
              },
              {
                "type": "object",
                "required": [
                  "status",
                  "reason",
                  "error"
                ],
                "properties": {
                  "error": {
                    "type": "string",
                    "description": "What went wrong reading the stored sets and what to do about it."
                  },
                  "reason": {
                    "type": "string",
                    "description": "Declared failure reason — one of upstream_error, upstream_timeout, upstream_rejected, rate_limited."
                  },
                  "status": {
                    "type": "string",
                    "const": "unread",
                    "description": "The stored sets could not be read; the call itself succeeded."
                  },
                  "retryAfter": {
                    "type": "integer",
                    "maximum": 9007199254740991,
                    "minimum": -9007199254740991,
                    "description": "Whole seconds to wait before reading again: the time until this server's own product budget frees a slot, or the Retry-After Open Food Facts sent with a 429. Absent when the failure named no wait."
                  }
                },
                "description": "The as-sold figures the record can still hold were not read, so the prepared figures are all this call holds. Never a failed call.",
                "additionalProperties": false
              }
            ],
            "description": "How the read of the record's stored as-sold figures went for this row. Present only when its product read returned prepared figures and no as-sold one, which is when that second read of the record's stored nutrition sets runs (Open Food Facts serves one preparation state per product read, and prepared wins)."
          },
          "energy_kcal_100g": {
            "type": "number",
            "description": "Energy in kcal per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "nutriscore_grade": {
            "type": "string",
            "description": "Nutri-Score grade: \"a\" through \"e\", \"unknown\" when the nutrition data entered is not enough to compute it, or \"not-applicable\" for product categories the score does not cover. Absent when Open Food Facts sent none."
          },
          "carbohydrates_100g": {
            "type": "number",
            "description": "Total carbohydrates in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          },
          "saturated_fat_100g": {
            "type": "number",
            "description": "Saturated fat in grams per 100 g, or per 100 ml for liquids, as sold. When the product read returned only prepared figures, read from the record's stored as-sold set instead (see stored_as_sold). Absent when not entered, or when that stored set was not read."
          }
        },
        "description": "A single product comparison row.",
        "additionalProperties": false
      },
      "description": "Comparison rows in input order — one per distinct barcode whose fetch completed, whether or not a record exists. Barcodes whose fetch failed have no row here; they appear in failed. Barcodes past the cap appear in unprocessed."
    },
    "not_found": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "Barcode with no contributor record, as provided in input."
      },
      "description": "Barcodes Open Food Facts answered for, confirming no contributor record exists. Not an error — the product may exist but not yet be entered. Never used for a fetch that failed."
    },
    "succeeded": {
      "type": "number",
      "description": "Number of barcodes that resolved to a found product."
    },
    "unprocessed": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "Barcode left unprocessed, as provided in input."
      },
      "description": "Distinct barcodes past the 10th, in input order — never fetched, so nothing is known about them. Absent when the batch held 10 or fewer distinct barcodes. Compare them in another off_compare_products call (2–10 barcodes), or look a single one up with off_get_product."
    },
    "mirror_snapshot": {
      "type": "string",
      "description": "Present only when a row's source is \"off_mirror\". States that those rows come from the local copy as of their mirror_as_of, so an edit made to them since then is not reflected."
    }
  },
  "additionalProperties": false
}
off_compare_products annotations
{
  "readOnlyHint": true,
  "openWorldHint": true,
  "idempotentHint": true
}
off_browse_taxonomy — Browse Food Facts Taxonomy
Advertised by the server during the last successful anonymous check.
Advertised read-onlyOpen-world

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.

off_browse_taxonomy input schema
{
  "type": "object",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "required": [
    "facet"
  ],
  "properties": {
    "facet": {
      "enum": [
        "categories",
        "labels",
        "allergens",
        "additives",
        "countries",
        "nova_groups",
        "nutrition_grades"
      ],
      "type": "string",
      "description": "\"categories\" covers food categories (en:cheeses, en:breakfast-cereals). \"labels\" covers certifications (en:organic, en:fair-trade). \"allergens\" covers declared allergens (en:milk, en:gluten). \"additives\" covers E-numbers (en:e322). \"countries\" covers country-of-sale tags (en:france). \"nova_groups\" and \"nutrition_grades\" are closed vocabularies returned complete; the other five are resolved against the Open Food Facts taxonomy."
    },
    "limit": {
      "type": "number",
      "default": 20,
      "minimum": 1,
      "multipleOf": 1,
      "description": "Maximum entries to return (default 20). One call returns at most 200: a larger value returns the first 200 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."
    },
    "search": {
      "type": "string",
      "description": "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."
    }
  },
  "additionalProperties": false
}
off_browse_taxonomy output schema
{
  "type": "object",
  "anyOf": [
    {
      "not": {
        "required": [
          "error"
        ]
      },
      "required": [
        "facet",
        "tags"
      ]
    },
    {
      "required": [
        "error"
      ]
    }
  ],
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "cap": {
      "type": "number",
      "description": "The limit that was applied — at most 200."
    },
    "tags": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Canonical tag ID (e.g. \"en:organic\"; bare \"1\"–\"4\" for NOVA groups, bare \"a\"–\"e\" for Nutri-Score grades). Pass this value through to the matching off_search_products filter parameter unchanged."
          },
          "name": {
            "type": "string",
            "description": "Human-readable display name (e.g. \"Organic\")."
          }
        },
        "description": "A single taxonomy tag entry with its canonical ID and display name.",
        "additionalProperties": false
      },
      "description": "Matching tag entries."
    },
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "integer",
          "maximum": 9007199254740991,
          "minimum": -9007199254740991,
          "description": "JSON-RPC error code for this failure."
        },
        "data": {
          "type": "object",
          "properties": {
            "reason": {
              "type": "string",
              "description": "Machine-readable failure mode."
            },
            "recovery": {
              "type": "object",
              "required": [
                "hint"
              ],
              "properties": {
                "hint": {
                  "type": "string"
                }
              },
              "description": "Actionable next step for the caller.",
              "additionalProperties": {}
            },
            "retryable": {
              "type": "boolean",
              "description": "Whether retrying may succeed."
            }
          },
          "additionalProperties": {}
        },
        "message": {
          "type": "string",
          "description": "Human-readable description of what went wrong."
        }
      },
      "description": "Present when the call failed. Absent on success.",
      "additionalProperties": {}
    },
    "facet": {
      "type": "string",
      "description": "The facet name that was queried (echoes the input)."
    },
    "shown": {
      "type": "number",
      "description": "Number of tags returned."
    },
    "notice": {
      "type": "string",
      "description": "Caveat about the answer — that the listing is a limited reference list rather than the full vocabulary, that the live vocabulary went unchecked and why (unreachable or no usable answer, Open Food Facts rate-limiting this server, or this server's own taxonomy budget spent, with the wait in seconds when one is known), that nothing matched and why, or that the limit cut matches, naming how many matched and the next call: a larger limit while one would list more, otherwise a narrower term, which is the only route past the first 200 (a limit above 200 is named too)."
    },
    "truncated": {
      "type": "boolean",
      "description": "True when more tags exist beyond the limit."
    },
    "total_in_facet": {
      "type": "number",
      "description": "Total entries in this facet. Present only for nova_groups and nutrition_grades, whose vocabularies are closed and complete. Absent for the other facets: Open Food Facts reports no match total and cannot enumerate them, so no figure would be a real one."
    }
  },
  "additionalProperties": false
}
off_browse_taxonomy annotations
{
  "readOnlyHint": true,
  "openWorldHint": true,
  "idempotentHint": true
}

Prompts

0 advertised

No prompt definitions available.

Resources

1 advertised

openfoodfacts-guide

Advertised by the server during the last successful anonymous check.

Judgment for the off_* tools (Open Food Facts) — allergy checks that never overclaim, choosing among a product's many barcodes, where tag IDs come from, grade ranges, free-from searches, and the search budget. Use when answering allergy, diet, ingredient, additive, Nutri-Score, NOVA, or product-comparison questions about packaged foods with off_get_product, off_search_products, off_compare_products, or off_browse_taxonomy.

openfoodfacts-guide URI
skill://openfoodfacts-guide/SKILL.md

MIME type: text/markdown

io.modelcontextprotocol/skills

Advertised by the server during the last successful anonymous check.

io.modelcontextprotocol/skills URI template
skill://{+path}

90-day uptime

A lightweight connection check about every six hours, sampled on hosts with many listings. It shows the server answered, not that its tools work.

Too few checks for a percentage yet (1 of 1 answered).

  1. 2026-11-07: 1 of 1 checks answered

30-day availability

Aggregated from read-only remote checks; this is not a tool-execution or security guarantee.

Successful checks
1 of 1 (100%)
Average latency
591 ms
95th percentile latency
591 ms

Maintenance history

Public check and source-refresh events. Editorial and ownership records remain private.

  • · probe · passed · 591 ms · metadata changed · 4 tool(s), 1 resource(s), 1 resource template(s), 0 prompt(s)

Connect from your client

Each snippet registers the same endpoint; pick the client you use.

This server is operated by the named third party. Listing and endpoint checks are not an endorsement or a security audit. Review the operator's permissions, terms, and data handling before connecting. How listings are checked and corrected.

Claude Code

Run this once in a terminal. It registers the remote server. Use --scope project to write .mcp.json for the repo.
Claude Code configuration
claude mcp add --transport http openfoodfacts-mcp-server https://openfoodfacts.caseyjhand.com/mcp

Codex

Run this once. Codex stores MCP servers in ~/.codex/config.toml (or .codex/config.toml in a trusted project).
Codex configuration
codex mcp add openfoodfacts-mcp-server --url https://openfoodfacts.caseyjhand.com/mcp

Cursor

Merge this server into .cursor/mcp.json in your project, then enable openfoodfacts-mcp-server in your MCP settings.
Cursor configuration
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "url": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}

Cline

Merge this into cline_mcp_settings.json (Cline → MCP Servers → Configure).
Cline configuration
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "streamableHttp",
      "url": "https://openfoodfacts.caseyjhand.com/mcp",
      "disabled": false
    }
  }
}

VS Code

Merge this server into .vscode/mcp.json. Use MCP: List Servers to start openfoodfacts-mcp-server and inspect its tools.
VS Code configuration
{
  "servers": {
    "openfoodfacts-mcp-server": {
      "type": "http",
      "url": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}

Gemini CLI

Run this once. It writes .gemini/settings.json in the current project; pass --scope user for ~/.gemini/settings.json.
Gemini CLI configuration
gemini mcp add --transport http openfoodfacts-mcp-server https://openfoodfacts.caseyjhand.com/mcp

Claude Desktop

Open Claude Settings > Connectors > Add custom connector, enter this server URL and enable it for the conversation. This is a manual remote-connector handoff, not a desktop JSON configuration.
Claude Desktop configuration
https://openfoodfacts.caseyjhand.com/mcp

Windsurf

Merge this into ~/.codeium/windsurf/mcp_config.json, then refresh MCP in Cascade. Windsurf remote servers use serverUrl, not url.
Windsurf configuration
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "serverUrl": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}

Let your agent find its tools

Find servers like openfoodfacts-mcp-server from your agent. Add Koskamo once to search the index for endpoints, access requirements, and client setup. Public discovery needs no key.