openfoodfacts-mcp-server

v0.3.8 pre-1.0

Look up food products by barcode, search by keyword, tag, allergen, or nutrition filter, compare products side-by-side, and browse the canonical tag vocabulary via MCP. STDIO or Streamable HTTP.

openfoodfacts.caseyjhand.com/mcp
claude mcp add --transport http openfoodfacts-mcp-server https://openfoodfacts.caseyjhand.com/mcp
codex mcp add openfoodfacts-mcp-server --url https://openfoodfacts.caseyjhand.com/mcp
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "url": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}
gemini mcp add --transport http openfoodfacts-mcp-server https://openfoodfacts.caseyjhand.com/mcp
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "command": "bunx",
      "args": [
        "mcp-remote",
        "https://openfoodfacts.caseyjhand.com/mcp"
      ]
    }
  }
}
{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "http",
      "url": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}
curl -X POST https://openfoodfacts.caseyjhand.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

Tools

4

off_get_product

open-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. 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.

read
invocation
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "off_get_product",
    "arguments": {
      "barcode": "<barcode>"
    }
  }
}
schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "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)."
    },
    "fields": {
      "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_unit brings the quantity it describes. requested_fields echoes the full set that was fetched.",
      "type": "array",
      "items": {
        "type": "string",
        "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"
        ],
        "description": "A specific product field to include in the response."
      }
    }
  },
  "required": [
    "barcode"
  ],
  "additionalProperties": false
}
view source ↗

off_search_products

open-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 — 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.

read
invocation
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "off_search_products",
    "arguments": {}
  }
}
schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "query": {
      "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 current data.",
      "type": "string"
    },
    "categories_tag": {
      "description": "Canonical category tag ID. Example: \"en:breakfast-cereals\", \"en:cheeses\". Use off_browse_taxonomy with facet=\"categories\" to discover valid values.",
      "type": "string"
    },
    "brands_tag": {
      "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.",
      "type": "string"
    },
    "labels_tag": {
      "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\".",
      "anyOf": [
        {
          "type": "string",
          "description": "One canonical label tag ID."
        },
        {
          "maxItems": 10,
          "type": "array",
          "items": {
            "type": "string",
            "description": "One canonical label tag ID."
          },
          "description": "Up to 10 canonical label tag IDs, all of which must apply."
        }
      ]
    },
    "allergens_tag": {
      "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.",
      "type": "string"
    },
    "traces_tag": {
      "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.",
      "type": "string"
    },
    "exclude_allergens": {
      "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.",
      "maxItems": 14,
      "type": "array",
      "items": {
        "type": "string",
        "description": "One canonical allergen tag ID to exclude, e.g. \"en:nuts\"."
      }
    },
    "exclude_traces": {
      "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.",
      "maxItems": 14,
      "type": "array",
      "items": {
        "type": "string",
        "description": "One canonical allergen tag ID to exclude as a trace, e.g. \"en:nuts\"."
      }
    },
    "ingredients_analysis_tag": {
      "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.",
      "type": "string",
      "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"
      ]
    },
    "additives_tag": {
      "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.",
      "type": "string"
    },
    "nutrition_grade": {
      "description": "Filter by Nutri-Score grade. \"a\" is highest nutritional quality, \"e\" is lowest. Products without a score are excluded.",
      "type": "string",
      "enum": [
        "a",
        "b",
        "c",
        "d",
        "e"
      ]
    },
    "nova_group": {
      "description": "Filter by NOVA food processing class. \"1\"=unprocessed/minimally processed, \"4\"=ultra-processed. Products without a NOVA score are excluded.",
      "type": "string",
      "enum": [
        "1",
        "2",
        "3",
        "4"
      ]
    },
    "countries_tag": {
      "description": "Canonical country tag ID. Example: \"en:france\", \"en:united-states\". Filters to products sold in that country.",
      "type": "string"
    },
    "nutrient_filters": {
      "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. Per-serving and prepared-product values are not searchable.",
      "maxItems": 18,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "nutrient": {
            "type": "string",
            "enum": [
              "energy-kcal",
              "fat",
              "saturated-fat",
              "carbohydrates",
              "sugars",
              "fiber",
              "proteins",
              "salt",
              "sodium"
            ],
            "description": "Nutrient to constrain, measured per 100 g, or per 100 ml for liquids. Energy is kilocalories; every other value is grams."
          },
          "operator": {
            "type": "string",
            "enum": [
              "lt",
              "lte",
              "gt",
              "gte"
            ],
            "description": "Comparison against value: \"lt\" below, \"lte\" at or below, \"gt\" above, \"gte\" at or above."
          },
          "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."
          }
        },
        "required": [
          "nutrient",
          "operator",
          "value"
        ],
        "additionalProperties": false,
        "description": "One numeric constraint on a nutrient value per 100 g, or per 100 ml for liquids."
      }
    },
    "sort_by": {
      "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.",
      "type": "string",
      "enum": [
        "last_modified_t",
        "unique_scans_n",
        "created_t",
        "popularity_key"
      ]
    },
    "page": {
      "default": 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 is served through page 10 only, so at page_size 50 it reaches the first 500 matches. 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 either bound is rejected rather than sent; narrow the filters or change sort_by to bring other products forward.",
      "type": "integer",
      "minimum": 1,
      "maximum": 9007199254740991
    },
    "page_size": {
      "default": 20,
      "description": "Results per page (default 20). One call returns at most 50: a larger value, up to 10000 (no backend serves past result 10000), 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.",
      "type": "integer",
      "minimum": 1,
      "maximum": 10000
    }
  },
  "required": [
    "page",
    "page_size"
  ],
  "additionalProperties": false
}
view source ↗

off_compare_products

open-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, sugars, salt, protein, and fiber per 100 g, or per 100 ml for liquids, plus Nutri-Score, NOVA group, and Green-Score. 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.

read
invocation
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "off_compare_products",
    "arguments": {
      "barcodes": "<barcodes>"
    }
  }
}
schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "barcodes": {
      "minItems": 2,
      "maxItems": 100,
      "type": "array",
      "items": {
        "type": "string",
        "pattern": "^0*[1-9]\\d{3,39}$",
        "description": "Product barcode, digits only: 4–40 digits after any leading zeros."
      },
      "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\"]."
    }
  },
  "required": [
    "barcodes"
  ],
  "additionalProperties": false
}
view source ↗

off_browse_taxonomy

open-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.

read
invocation
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "off_browse_taxonomy",
    "arguments": {
      "facet": "<facet>"
    }
  }
}
schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "facet": {
      "type": "string",
      "enum": [
        "categories",
        "labels",
        "allergens",
        "additives",
        "countries",
        "nova_groups",
        "nutrition_grades"
      ],
      "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."
    },
    "search": {
      "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.",
      "type": "string"
    },
    "limit": {
      "default": 20,
      "description": "Maximum entries to return (default 20). One call returns at most 100: a larger value, up to 200 (the most suggestions one lookup reads), returns the first 100 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.",
      "type": "integer",
      "minimum": 1,
      "maximum": 200
    }
  },
  "required": [
    "facet",
    "limit"
  ],
  "additionalProperties": false
}
view source ↗