Skip to content

Security ​

This page is the prose version of SECURITY.md.

Trust model ​

A Mealie API token acts as exactly one Mealie user and inherits that user's group, household and permission flags. Whoever holds it can read and rewrite that user's whole recipe collection, meal plans, shopping lists and cookbooks, and — through create_share_token — publish any recipe at a URL that needs no login.

Give the server a dedicated, non-admin user. Mealie's admin-only surface (backups, restore, user management, group settings, AI provider configuration) is not exposed by any tool here, but a token minted from an admin account still carries those rights if anything else ever reaches the API with it. MEALIE_READ_ONLY=true narrows the server further: the write and import tools are not registered at all.

Treat every environment variable this server reads as a secret. The MCP client process, and therefore the model driving it, sees every tool result — do not point this server at an instance whose data you would not put in a model's context.

Not exposed, on purpose ​

Everything under /api/admin (backups, restore, maintenance, user, group and household management, email, AI provider settings), /api/users/api-tokens (a tool that mints API credentials is privilege-escalation surface), the authentication routes, user CRUD and passwords, webhooks, event notifications and recipe actions (all three trigger outbound HTTP from the instance), meal plan rules, migrations, seeders, invitations, bulk export and ZIP download, and asset uploads. The one upload route that is exposed is a recipe's cover image, through set_recipe_image.

PUT /api/recipes/{slug} is not exposed either: it replaces the entire 33-field recipe object, so a partial update through it silently drops ingredients, steps and tags. update_recipe uses PATCH.

The confirmation, honestly ​

Sixteen tools ask a person before they act: deleting a recipe, organizer, cookbook, shopping list, shopping-list items, mealplan entry or comment; replacing written content through update_recipe, update_organizer, update_mealplan_entry or update_shopping_list_items; merging foods or units; and creating a cookbook or creating or revoking a public share link.

Where the MCP client supports elicitation, that is a dialog shown to whoever is sitting there — the model cannot answer it on their behalf, and nothing happens until an answer comes back.

Where the client cannot show a dialog, the tool falls back to a single-use token: the first call returns it together with a description of what is about to happen, the second call — same tool, same arguments, plus the token — performs it. Be clear about what that proves, because this server is: the call was made twice with the same arguments, and nothing more. A model can read the token out of the first result and quote it back in the same turn. The fallback text says so rather than implying somebody approved, and names whether it was the client that could not be asked or the operator who switched the dialog off with ELICITATION=false.

Either way the approval is bound to the specific target, so one issued for one target cannot be replayed against another. For operations on a set of ids it is bound to a fingerprint of the whole sorted set — an approval for three shopping-list items cannot delete a fourth appended in between — and for a merge to the direction as well, because swapping the arguments would destroy the wrong record.

Two on that list destroy nothing:

  • create_share_token widens who can see the data, and unlike a deletion the effect is invisible until someone uses the link.
  • delete_share_token narrows access, which is the safe direction — but the link cannot be reissued. A new share token is a different URL, so whoever was sent the old one simply finds a dead link, and this server cannot tell whom that was. It used to say in so many words that it needed no confirmation.

Confirmation prompts quote no upstream text — ids, counts and flags only — so a hostile recipe name cannot ride along into the prompt the user approves.

See Asking a person.

Untrusted content ​

Recipes are attacker-controlled text. A recipe imported from a website carries whatever that site wrote, and it stays in the database afterwards, so the content comes back through get_recipe and search_recipes long after the import. Comments come from other users of the instance. Every tool result that can contain instance content is therefore prefixed with an explicit untrusted-content marker telling the model to treat it as data, not as instructions.

Mealie performs the fetch, not this server. import_recipe_from_url and preview_recipe_url hand a URL to Mealie, which retrieves it from inside its own network and hands back what it read. URLs are therefore restricted to http and https — zod's .url() accepts file:, javascript: and data: — and loopback and link-local hosts are refused, including the cloud metadata endpoint 169.254.169.254, the hostnames that resolve to it on an instance (metadata.google.internal, instance-data), and the endpoints that sit outside that range: 100.100.100.200 (Alibaba Cloud) and 192.0.0.192 (Oracle).

Addresses are classified numerically, not by comparing strings: URL rewrites an IPv4-mapped IPv6 literal before any check sees it, so http://[::ffff:169.254.169.254]/ arrives as [::ffff:a9fe:a9fe] while a dual-stack client dials it as plain 169.254.169.254. What is sent to Mealie is the parsed URL rather than the string that came in, so the address that was checked is the one fetched.

A hostname is also resolved and every address behind it checked — which is more than Mealie does for itself, since Mealie looks only at the first gethostbyname answer. Be clear about what that step cannot do, though: a name this server fails to resolve within three seconds is passed on rather than refused. That is deliberate, because Mealie may sit in a different network with its own resolver — but it also means a resolver with DNS rebind protection, which is the normal setup for the self-hosting audience, turns "resolves to something internal" into "does not resolve" and hands the name straight through. http://169.254.169.254.nip.io/ is passed on for exactly that reason.

Private LAN ranges are allowed here as of 0.1.2, where earlier versions refused them. Do not read that as "Mealie can now be pointed at your LAN": Mealie has refused private addresses itself since v1.4.0, in its own HTTP transport, so such an import still fails — just further downstream and with a worse error message. The change is about having one classifier across these servers rather than about granting reach.

Two things this does not cover. import_recipe_from_html_or_json takes a document rather than a URL, and Mealie reads the image address out of that document and fetches it; this server never sees that address. And a redirect is a URL it never saw either. The real boundary is Mealie's own network egress.

Bounded responses ​

Oversized results drop whole items rather than cutting the JSON mid-string, and a response body is never read past 8 MB. Redirects are refused so the token cannot be resent to another host. The status of a response is decided before its body is read: an error body is cut at 64 KiB, so a proxy answering 401 with a login page of megabytes is reported as a 401, not as a size.

What the instance writes, on its way to the model ​

Every string that comes back from Mealie — a recipe name, a step, a comment, an error body, and the whole object in get_recipe's raw mode — is cleaned before it is shown: control characters, the zero-width set, the BiDi overrides and the byte-order mark are removed, and every string is cut at a length that fits its field, with the cut announced in the value. Identifiers, dates and URLs are validated rather than cleaned, because they have to round-trip; the credentials in a stored source URL are redacted. Fields whose name ends like a credential (password, secret, token, api_key, private_key, passphrase) are replaced by [redacted] at any depth — Mealie's extras is whatever an integration stored there.

The image scan of import_recipe_from_html_or_json reads the document the way Mealie's parsers read it: as JSON when it parses, as JSON text with escapes decoded, and as HTML with character references decoded, in the places extruct and recipe_scrapers read. An absolute address it cannot parse is refused rather than passed on, and so is a document naming more than 25 hosts or carrying more than 500 image references. The scan is one pass over the document.

Binding and freshness ​

The server negotiates both protocol revisions. On 2026-07-28 the sealed dialog state travels through the client; mcp-approval seals it (binding) and, since 0.8.1, spends a nonce on the first answer (freshness), accepted or declined. The record of spent nonces is per process — a restart forgets it — and the state's own fifteen-minute lifetime bounds that window. The two-call token of the fallback path is single-use in the same way.

Reporting a vulnerability ​

Use private vulnerability reporting. Do not open a public issue for an unpatched vulnerability, and do not include real credentials, tokens, hostnames or private configuration in a report. You can expect an initial response within a week; fixed vulnerabilities are published as a new release with a note in the changelog. Only the latest release and the current main branch receive security fixes.

Released under the MIT License.