Skip to main content
Glama
domdomegg

openfoodfacts-mcp

add_or_edit_product

Add or edit product data in the Open Food Facts database to improve searchability, calculate Nutri-Score and Eco-Score, and provide comprehensive food information.

Instructions

Add a new product or edit an existing one on Open Food Facts. Requires OFF_USER_ID and OFF_PASSWORD.

The more fields you fill, the more useful the entry. At minimum provide product_name, brands, and categories — these feed the search index, and a sparse entry won't be findable. If you have a photo of the pack, transcribe everything you can read: ingredients, nutrition, origins, traceability stamps, recycling icons, certifications.

Fields that drive derived data:

  • ingredients_text → allergens, additives, NOVA group, fruit/veg %

  • nutrition + categories → Nutri-Score

  • packagings + origins → Eco-Score

  • product_name + brands + categories + labels → search _keywords

Pitfalls learned the hard way:

  • Free-text packaging shapes get fuzzy-matched against the taxonomy. "Pouch" resolves to "en:pouch-flask" (a stand-up spouted pouch). Use taxonomy IDs like "en:bag" or "en:individual-bag" instead.

  • OFF has no generic "pouch" shape in its taxonomy. For vacuum-sealed individual portions use "en:individual-bag"; for plastic film wrap use "en:film".

Recommended workflow for adding a product from photos:

  1. Check if product exists with get_product first to avoid overwriting good data

  2. Upload photos with upload_image. Prefer more photos over fewer — panels with text (ingredients, nutrition, certifications, recycling instructions) are highest value as OFF can OCR them. Plain sides with just a colour or logo are lowest value but still worth uploading if you have them. Use the most appropriate imagefield (front, ingredients, nutrition, packaging) and "other" for the rest.

  3. Call this tool with all fields you can read from the photos. Set both quantity and serving_size.

  4. Set packagings_complete: true only when all packaging components are listed

For products with only prepared nutrition (jelly mixes, powdered drinks, etc.), use nutrition_prepared instead of nutrition. For values printed as "< 0.5g" on the packet, pass the string "< 0.5" — the less-than modifier will be preserved.

Nutrition fields mirror the label columns: nutrition (per 100g as sold), nutrition_per_serving (per serving as sold), nutrition_prepared (per 100g prepared), nutrition_prepared_per_serving (per serving prepared). OFF auto-derives per-serving from per-100g + serving_size, so nutrition_per_serving is only needed when the label shows explicit per-serving values you want to preserve.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
barcodeYesProduct barcode (EAN-13, UPC-A, EAN-8, etc.). Required. If the product doesn't exist yet, it will be created.
product_nameNoProduct name as it should appear in search results. Start from what's printed on the front of pack, but include the product type if it's not in the headline but is integral to the product — e.g. pack says "Fajita Halloumi" but it's a wrap, so use "High Protein Fajita Halloumi Wrap". Feeds search keywords.
generic_nameNoLegal name / product description, often found near the ingredients, e.g. "Carbonated no added sugar pineapple and grapefruit flavoured soft drink with sweeteners". Feeds search keywords.
brandsNoBrand name(s), comma-separated. For supermarket own-brands include both the sub-brand and the retailer, e.g. "The Fishmonger, Aldi" — this makes the product findable by either brand tag. Feeds search keywords.
quantityNoNet quantity as printed, e.g. "400g", "6 x 330ml", "1L". OFF parses this into product_quantity automatically. Always set this — it is separate from serving_size and OFF will warn "quantity undefined" without it.
categoriesNoCategories, comma-separated, most general first, e.g. "Seafood, Fishes, Salmons, Frozen fishes". Feeds search keywords and enables category browsing.
labelsNoCertifications, claims, and dietary marks, comma-separated, e.g. "Sustainable Seafood MSC, Vegan, High protein, No added sugar, Made in Scotland". OFF canonicalises these against its taxonomy.
ingredients_textNoFull ingredients list verbatim from the pack. Mark allergens with underscores, e.g. "Wholegrain _Wheat_ (53%), _Wheat_ Protein, Sugar, _Barley_ Malt Extract". OFF parses this to detect allergens, additives, and compute NOVA group. Percentages matter for Nutri-Score fruit/veg estimation.
allergensNoAllergens, comma-separated, e.g. "en:gluten, en:milk". Usually auto-detected from ingredients_text underscores, so only set this if ingredients are unavailable.
tracesNo"May contain" allergens, comma-separated, e.g. "en:nuts, en:peanuts, en:milk, en:sesame-seeds, en:soybeans".
originsNoWhere the ingredients come from, e.g. "Scotland" or "Northeast Pacific (FAO 67), Northwest Pacific (FAO 61)". Affects Eco-Score.
emb_codesNoTraceability/health marks — the oval stamp with a country code, e.g. "CN 2100/02398 EC" or "UK MD047 EC". Comma-separated if multiple.
manufacturing_placesNoWhere the product was made/packed, e.g. "Grimsby, United Kingdom".
countriesNoCountries where sold, comma-separated, e.g. "United Kingdom, Ireland".
storesNoRetailers where sold, comma-separated, e.g. "Aldi, Iceland".
packagingsNoStructured packaging components. Each item describes one physical part of the packaging (outer box, inner bag, lid, etc.). This populates the packagings array the UI displays and feeds the Eco-Score. Use taxonomy IDs ("en:box") not free text.
packagings_completeNoSet to true to mark that all packaging components have been listed. Only set this when you are confident the packagings array is complete.
packaging_textNoRecycling instructions and/or packaging information as printed on the pack, e.g. "Tray - Plastic - Recycle\nFilm - Plastic - Do Not Recycle". This is the human-readable text, separate from the structured packagings array.
serving_sizeNoServing size as printed, e.g. "30g", "100g (1 fillet)", "330ml (1 can)". OFF uses this to derive per-serving values from per-100g when per-serving values are not provided explicitly.
nutritionNoNutrition facts as sold, per 100g (or per 100ml for beverages). Transcribe per-100g values exactly as printed — don't back-calculate from per-serving. For values printed as "< 0.5g" on the packet, pass the string "< 0.5" — the less-than modifier will be preserved.
nutrition_per_servingNoNutrition facts as sold, per serving. Use this when the label shows a separate per-serving column alongside per-100g. If omitted and serving_size is set, OFF auto-derives per-serving from per-100g values.
nutrition_preparedNoNutrition facts as prepared, per 100g (or per 100ml). For products like jelly mixes, powdered drinks, instant noodles — anything where the packet shows separate "as prepared" nutrition values.
nutrition_prepared_per_servingNoNutrition facts as prepared, per serving. Use this when the label shows a separate per-serving column for prepared values.
commentNoEdit comment explaining what was changed, shown in product edit history. E.g. "Add nutrition data from packaging photo".
languageNoLanguage code for language-dependent fields (product_name, generic_name, ingredients_text). Defaults to "en". Set to "fr" for French products, etc. This determines which language version of these fields is written.en
extra_fieldsNoRaw form fields for anything else. Useful for less common nutriments (nutriment_sodium, nutriment_calcium, nutriment_vitamin-c) or fields not exposed above. Values are strings.

Implementation Reference

  • The tool 'add_or_edit_product' is registered and implemented in this function. It uses a Zod schema for input validation, handles API requests to Open Food Facts, and supports both v2 form updates and v3 PATCH updates for structured packaging data.
    export function registerAddOrEditProduct(server: McpServer, config: Config): void {
    	server.registerTool(
    		'add_or_edit_product',
    		{
    			title: 'Add or edit product',
    			description: `Add a new product or edit an existing one on Open Food Facts. Requires OFF_USER_ID and OFF_PASSWORD.
    
    The more fields you fill, the more useful the entry. At minimum provide product_name, brands, and categories — these feed the search index, and a sparse entry won't be findable. If you have a photo of the pack, transcribe everything you can read: ingredients, nutrition, origins, traceability stamps, recycling icons, certifications.
    
    Fields that drive derived data:
    - ingredients_text → allergens, additives, NOVA group, fruit/veg %
    - nutrition + categories → Nutri-Score
    - packagings + origins → Eco-Score
    - product_name + brands + categories + labels → search _keywords
    
    Pitfalls learned the hard way:
    - Free-text packaging shapes get fuzzy-matched against the taxonomy. "Pouch" resolves to "en:pouch-flask" (a stand-up spouted pouch). Use taxonomy IDs like "en:bag" or "en:individual-bag" instead.
    - OFF has no generic "pouch" shape in its taxonomy. For vacuum-sealed individual portions use "en:individual-bag"; for plastic film wrap use "en:film".
    
    Recommended workflow for adding a product from photos:
    1. Check if product exists with get_product first to avoid overwriting good data
    2. Upload photos with upload_image. Prefer more photos over fewer — panels with text (ingredients, nutrition, certifications, recycling instructions) are highest value as OFF can OCR them. Plain sides with just a colour or logo are lowest value but still worth uploading if you have them. Use the most appropriate imagefield (front, ingredients, nutrition, packaging) and "other" for the rest.
    3. Call this tool with all fields you can read from the photos. Set both quantity and serving_size.
    4. Set packagings_complete: true only when all packaging components are listed
    
    For products with only prepared nutrition (jelly mixes, powdered drinks, etc.), use nutrition_prepared instead of nutrition.
    For values printed as "< 0.5g" on the packet, pass the string "< 0.5" — the less-than modifier will be preserved.
    
    Nutrition fields mirror the label columns: nutrition (per 100g as sold), nutrition_per_serving (per serving as sold), nutrition_prepared (per 100g prepared), nutrition_prepared_per_serving (per serving prepared). OFF auto-derives per-serving from per-100g + serving_size, so nutrition_per_serving is only needed when the label shows explicit per-serving values you want to preserve.`,
    			inputSchema,
    			annotations: {
    				readOnlyHint: false,
    			},
    		},
    		async (args) => {
    			const body: Record<string, string> = {
    				code: args.barcode,
    				// Set lc so language-dependent fields (product_name, generic_name,
    				// ingredients_text) are stored under the correct language suffix
    				// regardless of the product's primary language.
    				lc: args.language as string,
    			};
    
    			if (args.product_name !== undefined) {
    				body.product_name = args.product_name;
    			}
    
    			if (args.generic_name !== undefined) {
    				body.generic_name = args.generic_name;
    			}
    
    			if (args.brands !== undefined) {
    				body.brands = args.brands;
    			}
    
    			if (args.quantity !== undefined) {
    				body.quantity = args.quantity;
    			}
    
    			if (args.categories !== undefined) {
    				body.categories = args.categories;
    			}
    
    			if (args.labels !== undefined) {
    				body.labels = args.labels;
    			}
    
    			if (args.ingredients_text !== undefined) {
    				body.ingredients_text = args.ingredients_text;
    			}
    
    			if (args.allergens !== undefined) {
    				body.allergens = args.allergens;
    			}
    
    			if (args.traces !== undefined) {
    				body.traces = args.traces;
    			}
    
    			if (args.origins !== undefined) {
    				body.origins = args.origins;
    			}
    
    			if (args.emb_codes !== undefined) {
    				body.emb_codes = args.emb_codes;
    			}
    
    			if (args.manufacturing_places !== undefined) {
    				body.manufacturing_places = args.manufacturing_places;
    			}
    
    			if (args.countries !== undefined) {
    				body.countries = args.countries;
    			}
    
    			if (args.stores !== undefined) {
    				body.stores = args.stores;
    			}
    
    			if (args.serving_size !== undefined) {
    				body.serving_size = args.serving_size;
    			}
    
    			if (args.packaging_text !== undefined) {
    				body.packaging_text_en = args.packaging_text;
    			}
    
    			if (args.comment !== undefined) {
    				body.comment = args.comment;
    			}
    
    			if (args.nutrition) {
    				addNutritionParams(body, args.nutrition as Record<string, unknown>, 'as_sold');
    			}
    
    			if (args.nutrition_per_serving) {
    				addNutritionParams(body, {...args.nutrition_per_serving as Record<string, unknown>, per: 'serving'}, 'as_sold');
    			}
    
    			if (args.nutrition_prepared) {
    				addNutritionParams(body, args.nutrition_prepared as Record<string, unknown>, 'prepared');
    			}
    
    			if (args.nutrition_prepared_per_serving) {
    				addNutritionParams(body, {...args.nutrition_prepared_per_serving as Record<string, unknown>, per: 'serving'}, 'prepared');
    			}
    
    			if (args.extra_fields) {
    				for (const [key, value] of Object.entries(args.extra_fields as Record<string, string>)) {
    					body[key] = value;
    				}
    			}
    
    			const results: Record<string, unknown> = {};
    
    			// Only call the v2 form endpoint if there's something to write beyond the barcode
    			if (Object.keys(body).length > 1) {
    				results.fields = await offPost(config, '/cgi/product_jqm2.pl', body);
    			}
    
    			// Structured packagings go through v3 PATCH — the v2 form endpoint doesn't
    			// populate the packagings array that the UI reads.
    			const needsV3Patch = (args.packagings && (args.packagings as unknown[]).length > 0) || args.packagings_complete !== undefined;
    			if (needsV3Patch) {
    				type PackagingInput = z.infer<typeof packagingComponentSchema>;
    				const v3Product: Record<string, unknown> = {};
    
    				if (args.packagings && (args.packagings as unknown[]).length > 0) {
    					const components = (args.packagings as PackagingInput[]).map((p) => {
    						const c: Record<string, unknown> = {
    							number_of_units: p.number_of_units,
    							shape: {id: p.shape},
    						};
    						if (p.material !== undefined) {
    							c.material = {id: p.material};
    						}
    
    						if (p.recycling !== undefined) {
    							c.recycling = {id: p.recycling};
    						}
    
    						if (p.quantity_per_unit !== undefined) {
    							c.quantity_per_unit = p.quantity_per_unit;
    						}
    
    						if (p.weight_measured !== undefined) {
    							c.weight_measured = p.weight_measured;
    						}
    
    						return c;
    					});
    					v3Product.packagings = components;
    				}
    
    				if (args.packagings_complete !== undefined) {
    					v3Product.packagings_complete = args.packagings_complete ? 1 : 0;
    				}
    
    				const fields = Object.keys(v3Product).join(',');
    				results.packagings = await offJsonBody(config, 'PATCH', `/api/v3/product/${args.barcode}`, {
    					fields,
    					product: v3Product,
    				});
    			}
    
    			return jsonResult(results);
    		},
    	);
    }
  • The Zod input schema for the 'add_or_edit_product' tool, defining all accepted parameters for creating or editing product data.
    const inputSchema = strictSchemaWithAliases(
    	{
    		barcode: z.string().describe('Product barcode (EAN-13, UPC-A, EAN-8, etc.). Required. If the product doesn\'t exist yet, it will be created.'),
    
    		// Identity — these feed the search index (_keywords)
    		product_name: z.string().optional().describe('Product name as it should appear in search results. Start from what\'s printed on the front of pack, but include the product type if it\'s not in the headline but is integral to the product — e.g. pack says "Fajita Halloumi" but it\'s a wrap, so use "High Protein Fajita Halloumi Wrap". Feeds search keywords.'),
    		generic_name: z.string().optional().describe('Legal name / product description, often found near the ingredients, e.g. "Carbonated no added sugar pineapple and grapefruit flavoured soft drink with sweeteners". Feeds search keywords.'),
    		brands: z.string().optional().describe('Brand name(s), comma-separated. For supermarket own-brands include both the sub-brand and the retailer, e.g. "The Fishmonger, Aldi" — this makes the product findable by either brand tag. Feeds search keywords.'),
    		quantity: z.string().optional().describe('Net quantity as printed, e.g. "400g", "6 x 330ml", "1L". OFF parses this into product_quantity automatically. Always set this — it is separate from serving_size and OFF will warn "quantity undefined" without it.'),
    
    		// Classification — also feeds _keywords
    		categories: z.string().optional().describe('Categories, comma-separated, most general first, e.g. "Seafood, Fishes, Salmons, Frozen fishes". Feeds search keywords and enables category browsing.'),
    		labels: z.string().optional().describe('Certifications, claims, and dietary marks, comma-separated, e.g. "Sustainable Seafood MSC, Vegan, High protein, No added sugar, Made in Scotland". OFF canonicalises these against its taxonomy.'),
    
    		// Composition
    		ingredients_text: z.string().optional().describe('Full ingredients list verbatim from the pack. Mark allergens with underscores, e.g. "Wholegrain _Wheat_ (53%), _Wheat_ Protein, Sugar, _Barley_ Malt Extract". OFF parses this to detect allergens, additives, and compute NOVA group. Percentages matter for Nutri-Score fruit/veg estimation.'),
    		allergens: z.string().optional().describe('Allergens, comma-separated, e.g. "en:gluten, en:milk". Usually auto-detected from ingredients_text underscores, so only set this if ingredients are unavailable.'),
    		traces: z.string().optional().describe('"May contain" allergens, comma-separated, e.g. "en:nuts, en:peanuts, en:milk, en:sesame-seeds, en:soybeans".'),
    
    		// Provenance
    		origins: z.string().optional().describe('Where the ingredients come from, e.g. "Scotland" or "Northeast Pacific (FAO 67), Northwest Pacific (FAO 61)". Affects Eco-Score.'),
    		emb_codes: z.string().optional().describe('Traceability/health marks — the oval stamp with a country code, e.g. "CN 2100/02398 EC" or "UK MD047 EC". Comma-separated if multiple.'),
    		manufacturing_places: z.string().optional().describe('Where the product was made/packed, e.g. "Grimsby, United Kingdom".'),
    		countries: z.string().optional().describe('Countries where sold, comma-separated, e.g. "United Kingdom, Ireland".'),
    		stores: z.string().optional().describe('Retailers where sold, comma-separated, e.g. "Aldi, Iceland".'),
    
    		// Packaging — uses v3 PATCH under the hood; this is what the web UI actually renders
    		packagings: z.array(packagingComponentSchema).optional().describe('Structured packaging components. Each item describes one physical part of the packaging (outer box, inner bag, lid, etc.). This populates the packagings array the UI displays and feeds the Eco-Score. Use taxonomy IDs ("en:box") not free text.'),
    		packagings_complete: z.boolean().optional().describe('Set to true to mark that all packaging components have been listed. Only set this when you are confident the packagings array is complete.'),
    		packaging_text: z.string().optional().describe('Recycling instructions and/or packaging information as printed on the pack, e.g. "Tray - Plastic - Recycle\\nFilm - Plastic - Do Not Recycle". This is the human-readable text, separate from the structured packagings array.'),
    
    		// Nutrition
    		serving_size: z.string().optional().describe('Serving size as printed, e.g. "30g", "100g (1 fillet)", "330ml (1 can)". OFF uses this to derive per-serving values from per-100g when per-serving values are not provided explicitly.'),
    		nutrition: nutritionSchema.optional().describe('Nutrition facts as sold, per 100g (or per 100ml for beverages). Transcribe per-100g values exactly as printed — don\'t back-calculate from per-serving. For values printed as "< 0.5g" on the packet, pass the string "< 0.5" — the less-than modifier will be preserved.'),
    		nutrition_per_serving: nutritionPerServingSchema.optional().describe('Nutrition facts as sold, per serving. Use this when the label shows a separate per-serving column alongside per-100g. If omitted and serving_size is set, OFF auto-derives per-serving from per-100g values.'),
    		nutrition_prepared: nutritionSchema.optional().describe('Nutrition facts as prepared, per 100g (or per 100ml). For products like jelly mixes, powdered drinks, instant noodles — anything where the packet shows separate "as prepared" nutrition values.'),
    		nutrition_prepared_per_serving: nutritionPerServingSchema.optional().describe('Nutrition facts as prepared, per serving. Use this when the label shows a separate per-serving column for prepared values.'),
    
    		// Edit metadata
    		comment: z.string().optional().describe('Edit comment explaining what was changed, shown in product edit history. E.g. "Add nutrition data from packaging photo".'),
    		language: z.string().default('en').describe('Language code for language-dependent fields (product_name, generic_name, ingredients_text). Defaults to "en". Set to "fr" for French products, etc. This determines which language version of these fields is written.'),
    
    		// Escape hatch for anything not covered above
    		extra_fields: z.record(z.string()).optional().describe('Raw form fields for anything else. Useful for less common nutriments (nutriment_sodium, nutriment_calcium, nutriment_vitamin-c) or fields not exposed above. Values are strings.'),
    	},
    	{
    		code: 'barcode',
    	},
    );
  • Registration of the 'add_or_edit_product' tool in the tool suite.
    registerAddOrEditProduct(server, config);

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/domdomegg/openfoodfacts-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server