Skip to main content
Glama
dvejsada

mealie-mcp

by dvejsada

mealie-mcp

A Model Context Protocol server for Mealie, built with FastMCP and served over the Streamable HTTP transport. Ships as a Docker container.

It exposes tools only — no MCP resources or prompts. It is read-only by default; write tools can be enabled with MEALIE_READONLY=false.

How auth works

There are two independent credentials:

Credential

Header

Purpose

MCP endpoint token

Authorization: Bearer <token>

A static secret (set via MCP_AUTH_TOKEN) that gates the server. Requests without a valid token are rejected with 401.

Mealie API token

X-Mealie-Token: <token>

Supplied per request by each client. The server forwards it to Mealie as a bearer token, so one server can serve many Mealie users.

Optionally, a client can target a different Mealie instance per request with the X-Mealie-Url: https://other-mealie.example.com header (otherwise MEALIE_BASE_URL is used).

Get a Mealie API token from your Mealie profile: Profile → Manage API Tokens.

Tools

Read tools (always available)

Recipessearch_recipes, get_recipe, get_recipe_suggestions, get_random_recipe Reference datalist_categories, list_tags, list_tools, list_foods, list_units, list_labels, list_cookbooks Householdget_shopping_lists, get_shopping_list, get_meal_plan, get_todays_meals Instanceget_current_user, get_app_info

Write tools (only when MEALIE_READONLY=false)

Recipescreate_recipe_from_url, create_recipe, update_recipe, delete_recipe, mark_recipe_made Shoppingadd_shopping_item, add_shopping_items, set_shopping_item_checked, add_recipe_to_shopping_list Meal planscreate_mealplan_entry, update_mealplan_entry, delete_mealplan_entry

Write tools respect the per-request Mealie token's own permissions, so a read-only Mealie token can never mutate data even when write tools are enabled.

Names, not just IDs. Tools that take a food, unit, or label (e.g. get_recipe_suggestions, add_shopping_item, add_shopping_items) accept a plain name or a UUID — names are resolved server-side, so a client doesn't need a separate list_* lookup first. get_recipe returns a trimmed view by default (pass include_internal=true for the raw Mealie object), and add_shopping_items adds many items in one call.

Run with Docker

cp .env.example .env
# edit .env: set MCP_AUTH_TOKEN (a long random secret) and MEALIE_BASE_URL
docker compose up --build -d

The MCP endpoint is then available at http://<host>:8000/mcp, with an unauthenticated liveness probe at http://<host>:8000/healthz.

The endpoint token is the only thing standing between the internet and your Mealie instance. Put the server behind a reverse proxy with TLS, or on a private network, and use a long random MCP_AUTH_TOKEN.

Run locally (without Docker)

pip install -r requirements.txt
export MCP_AUTH_TOKEN="a-long-random-secret"
export MEALIE_BASE_URL="https://mealie.example.com"
python main.py

Connecting a client

Point your MCP client at the Streamable HTTP endpoint and send both headers. Example with the FastMCP client:

from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

transport = StreamableHttpTransport(
    url="http://localhost:8000/mcp",
    headers={
        "Authorization": "Bearer <your MCP_AUTH_TOKEN>",
        "X-Mealie-Token": "<your Mealie API token>",
    },
)

async with Client(transport) as client:
    tools = await client.list_tools()
    result = await client.call_tool("search_recipes", {"search": "soup"})

LibreChat

See examples/librechat.yaml for a ready-to-use mcpServers entry. It maps the endpoint bearer token to a LibreChat environment variable and each user's Mealie token to a per-user customUserVars field, so every LibreChat user acts as their own Mealie account.

Set requiresOAuth: false on the server entry. This server uses a static Authorization Bearer gate, not OAuth. Without the flag, LibreChat's OAuth auto-detection probes the endpoint without your headers, gets a 401, and misclassifies the server as OAuth-protected — so it never sends your token (you'll see 401s followed by /.well-known/oauth-* 404s). See Troubleshooting.

Configuration

Variable

Required

Default

Description

MCP_AUTH_TOKEN

yes

Comma-separated secret bearer token(s) for the MCP endpoint.

MEALIE_BASE_URL

no¹

Default Mealie base URL (e.g. https://mealie.example.com).

MEALIE_READONLY

no

true

Set false to also register write tools.

MEALIE_TIMEOUT

no

30

Outbound request timeout in seconds.

MEALIE_VERIFY_SSL

no

true

Set false for self-signed Mealie certs.

MCP_HOST

no

0.0.0.0

Bind address.

MCP_PORT

no

8000

Bind port.

MCP_PATH

no

/mcp

Endpoint path.

MCP_LOG_LEVEL

no

info

uvicorn log level.

MCP_AUTH_DEBUG

no

false

Log a masked diagnostic for each request hitting the auth gate (to troubleshoot 401s). See Troubleshooting.

¹ Required unless every client sends the X-Mealie-Url header.

Troubleshooting 401 Unauthorized

A 401 means the endpoint token (the Authorization: Bearer <token> gate, not your Mealie token) was missing or didn't match MCP_AUTH_TOKEN. Many MCP clients react to a 401 by probing for OAuth (GET /.well-known/oauth-*, which this server returns 404 for, since it uses static tokens, not OAuth) — those 404s are a symptom of the 401, not a separate problem.

To see exactly what the gate receives, set MCP_AUTH_DEBUG=true and reconnect. Each request to the gate is logged with a masked summary (the token itself is never logged — only its length and a SHA-256 fingerprint):

auth-debug enabled: accepting 1 token(s) with fingerprints ['0c45a1f1']
auth-debug: POST /mcp authorization=scheme=Bearer token_len=10 fp=0c45a1f1 matches_configured=True -> 200
auth-debug: POST /mcp authorization=scheme=Bearer token_len=5 fp=02b60b3b matches_configured=False -> 401
auth-debug: POST /mcp authorization=absent (no Authorization header reached the server) -> 401
auth-debug: POST /mcp authorization=scheme=Bearer <empty token> -> 401

Read it as:

  • matches_configured=False — the client sent a token, but it doesn't equal any MCP_AUTH_TOKEN value. Check for typos, quoting, or trailing whitespace.

  • absent — no Authorization header reached the server. The client isn't sending it, or a reverse proxy/ingress stripped it before it arrived. With LibreChat this is usually OAuth auto-detection probing without your headers — set requiresOAuth: false on the server entry (see above).

  • <empty token> — the client sent Authorization: Bearer with no value, typically an unset ${...} variable in the client config.

  • matches_configured=True -> 200 — the gate is fine; the problem is elsewhere.

Docker Hub images

Released versions are published to Docker Hub at georgx22/mealie-mcp (multi-arch: linux/amd64, linux/arm64):

docker run -d -p 8000:8000 \
  -e MCP_AUTH_TOKEN="a-long-random-secret" \
  -e MEALIE_BASE_URL="https://mealie.example.com" \
  georgx22/mealie-mcp:latest

CI/CD

Three GitHub Actions workflows are included (.github/workflows/):

  • ci.yml — runs ruff, pyright, and pytest on every push/PR (Python 3.11–3.13).

  • docker-publish.yml — builds and pushes the multi-arch image to Docker Hub when a GitHub Release is published (tags X.Y.Z, X.Y, X, and latest).

  • claude.yml — runs Claude Code when someone mentions @claude in an issue, PR, or review comment.

Configure these repository secrets (Settings → Secrets and variables → Actions):

Secret

Used by

How to get it

DOCKERHUB_USERNAME

docker-publish

Your Docker Hub username (with push access to georgx22/mealie-mcp).

DOCKERHUB_TOKEN

docker-publish

A Docker Hub access token (Account Settings → Security → New Access Token).

CLAUDE_CODE_OAUTH_TOKEN

claude

Run claude setup-token locally (Claude Pro/Max), paste the token.

To cut a release (which triggers the image build):

gh release create v0.1.0 --generate-notes

Development

pip install -e ".[dev]"
ruff check .     # lint
pyright          # type check
pytest -q        # tests

See CONTRIBUTING.md for details. Changes are tracked in CHANGELOG.md. Licensed under MIT.