mcp server
meals
A meal options planner with an aisle-sorted shopping list and a first-class AI access layer: REST API, MCP server, and a skill the server publishes itself. No built-in LLM — bring your own.
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 21, 2026Meals
A meal options planner (not a rigid Mon–Sun grid) with a recipe library and
an aisle-sorted shopping list, exposed through an AI-friendly API so anyone can
drive it with their own AI assistant. POC implementation of the plan in
planning/.
What it does
- Recipes — submit a URL once and it's parsed (free, from the page's schema.org JSON-LD) and cached forever; pages without structured data are handed to your AI to parse and submit back. Manual family recipes work too, and anything can be deleted from the app.
- Meals — the unit of planning: recipes + loose ingredients ("cottage pie with peas and carrots" needs no carrot recipe). Editable in place — add a recipe, drop a side — and the shopping list follows the change without un-ticking what's already in the trolley.
- Cooked history — marking a meal cooked is recorded permanently, per recipe as well as per meal, and survives deleting the plan or the meal. The library shows "cooked 12× · last May 2026" and sorts by most cooked or not had in a while.
- Plans — weekly-ish pools of meal options grouped by slot (dinners, lunches). No days, no dates: an unexpected trip breaks nothing.
- Shopping list — auto-populated from the plan with full provenance (every item knows which meals need it), exact-unit merging, ad-hoc items, staples check, "already have it", and store-walking aisle order (🥬 → 🍞 → 🥩 → …).
- AI access layer — the headline: a documented REST API, an MCP server
with 34 task-level tools, and a skill/prompt pack the server publishes
itself at
/skill+/prompt-pack. The app ships no built-in LLM — bring your own. - Households — a household is one recipe library, plan and shopping list,
and it's the whole authorisation boundary. Registering creates a household
of your own; the people you cook with join it with a single-use invite code
(
XXXX-XXXX-XXXX, short enough to read off one phone and type into another), either when they sign up or from inside an account they already have. Everyone in a household can do everything to the food — there is no read-only member and no per-recipe permission. The one exception is the guest list: each household has a lead (the member it would be billed to) who invites and removes people. Leaving is always your own to do, and nobody is deleted by being removed — they land in an empty household of their own, account and tokens intact. - Auth — real per-user accounts (bcrypt + opaque bearer tokens), plus
per-user API tokens (PATs) for AI clients. Users can change their own password
(
POST /auth/password, or from the app's menu): it revokes every session token and issues a fresh one, so the device doing the change stays logged in and the others don't. Forgotten passwords reset by emailed code, and accounts can be deleted from inside the app — the last member of a household takes its data with them, anyone else takes only themselves.
Quickstart
make dev # full stack in Docker: Postgres + API on http://localhost:8000
make seed # demo user, recipes, a plan, and a ready-made shopping list
The web app is at http://localhost:8000/app/ (a browser at the bare
address is redirected there). Interactive API docs: http://localhost:8000/docs. The seed's demo account is
demo@example.com / demo-password-123, and it prints an API token you can
use immediately with curl or the MCP server. (The password lives here rather
than in the seed's output: make seed also fills real accounts on real servers
— SEED_EMAIL / SEED_PASSWORD — and a password echoed to a terminal outlives
the run.)
No Docker? make run starts the API locally on SQLite (zero services), and
make test runs the whole suite the same way.
The stack's Postgres listens on 127.0.0.1:5433 only (for make db), and its
password is meals unless POSTGRES_PASSWORD says otherwise, either in the
environment or in a .env beside docker-compose.yml. That default is for a
laptop. Anywhere else, set your own before the first make up, because
Postgres only reads it when it creates the database volume.
make help # everything else: logs, lint, migrate, fmt, down, nuke…
One container, no checkout
Every release publishes an image that is the whole product: API, web client,
the published skill, and the MCP endpoint at /mcp, defaulting to SQLite
under /data. It runs as uid 1000 and needs no arguments, which is what makes
it work on hosts that give you exactly one container:
docker run -d -p 8000:8000 -v yamp-data:/data ghcr.io/marco308/meals:latest
Point DATABASE_URL at Postgres when you outgrow that
(postgresql+asyncpg://user:pass@host:5432/meals); the schema migrates itself
on boot either way. With a bind mount rather than a named volume, chown 1000:1000 the directory first, since Docker only copies ownership into empty
named volumes. Back up the whole directory rather than meals.db alone: the
database runs in SQLite's WAL mode, so the latest writes can still be sitting
in meals.db-wal beside it. ghcr.io/marco308/meals-mcp is the MCP server on
its own, for deployments that want it on a separate host.
Sending email (optional)
Only password reset sends any, and it's plain SMTP so any relay works. Leave it
unset and POST /auth/password-reset returns a 503 explaining what's missing;
everything else, including changing a password you know, works without it.
GET /client-config reports which of the two you are as password_reset_enabled,
so a client can hide the option rather than offer a door that doesn't open.
SMTP_HOST=smtp.example.com
SMTP_PORT=587 # default
SMTP_FROM=meals@example.com
SMTP_USERNAME=... # if your relay authenticates
SMTP_PASSWORD=...
SMTP_START_TLS=true # default
PASSWORD_RESET_TTL_MINUTES=30 # default
SMTP_TIMEOUT_SECONDS=10 # default: the whole send, then it counts as failed
Two relays that need no server of your own. Resend — verify your domain,
then the username is literally resend and the password is the API key:
SMTP_HOST=smtp.resend.com
SMTP_USERNAME=resend
SMTP_PASSWORD=re_...
SMTP_FROM=meals@your-domain
Gmail — needs 2FA, then an app password from Google Account → Security → App
passwords. SMTP_FROM must be the account itself; Google rejects any other From.
SMTP_HOST=smtp.gmail.com
SMTP_USERNAME=you@gmail.com
SMTP_PASSWORD=<16-character app password>
SMTP_FROM=you@gmail.com
Behind a reverse proxy
The auth endpoints are rate-limited per client address (AUTH_RATE_LIMIT_PER_MINUTE,
10 by default). Behind a proxy, the address the app sees is the proxy's unless
uvicorn has been told to trust it, and then every caller shares one bucket: one
client's failed sign-ins count against everybody's. Tell it with
FORWARDED_ALLOW_IPS, which uvicorn reads from the process environment (it is not
one of the app's settings, so a line in backend/.env doesn't reach it):
FORWARDED_ALLOW_IPS=172.18.0.2 # the proxy's address, as the container sees it
FORWARDED_ALLOW_IPS=10.0.0.0/8 # or a network, such as the overlay a swarm proxy is on
uvicorn then takes the client from X-Forwarded-For, reading from the right and
stopping at the first address it doesn't trust, so a caller can't pick their own
bucket by sending the header themselves. Trust only what can reach the port:
* hands that choice to anyone who can connect directly. With two proxies in a
row (a CDN in front of your own), the inner one has to be told to trust the outer
one, or it replaces the header and every client looks like the CDN. If every
auth.rate_limited line in the log names the same bucket, this is why.
Logs
Everything goes to stdout, one line per record: an access line per request
(with a request id, echoed as X-Request-ID and quoted in any 500), and named
events for the moments worth finding later — registrations, deletions, ingest
outcomes. In production (ENVIRONMENT=production) lines are JSON so docker logs pipes straight into a log shipper; anywhere else they're plain text.
LOG_FORMAT=json|text overrides, LOG_LEVEL=INFO is the default. Logs carry
ids, never emails, tokens, or what anyone is cooking.
Metrics
Set METRICS_TOKEN and GET /metrics serves Prometheus text format to
Authorization: Bearer <that token>; unset (the default), the endpoint 404s
and no metrics work runs at all. You get request counts and latency
histograms by route template, a counter per named event from the log, process
metrics, and slow-moving usage gauges (households, users, recipes) refreshed
once a minute. Scrapes and healthchecks don't count themselves, so a quiet
family server's graphs show the family, not the monitoring.
Backups
The stack backs itself up: a sidecar takes a nightly pg_dump, checks it can
be read back before counting it, and keeps 7 daily and 4 weekly. Set
RCLONE_REMOTE and a passphrase and it also puts a gpg-encrypted copy wherever
rclone can reach — Drive, S3, another box in the house —
because a dump next to the database survives DROP TABLE and nothing else.
docker compose exec backup backup.sh # now, not tonight
docker compose exec backup restore.sh --target meals_check --drop latest # prove it
The container goes unhealthy if the newest dump ages past 36h, and CI restores
a dump into a scratch database on every push, because an untested backup is a
hypothesis. Details, and how to actually recover, in
backup/README.md.
Repo layout
| Directory | Contents |
|---|---|
backend/ | FastAPI + async SQLAlchemy + Alembic. Postgres in Docker, SQLite for local/tests |
web/ | Web app, served by the API itself at /app — the big-screen client. Plain HTML/CSS/ES modules, no build step |
ios/ | Native SwiftUI iPhone app: plan, recipe library + URL ingest, and an offline-first shopping list |
mcp/ | MCP server wrapping the API with task-level tools (ingest_recipe, get_shopping_list, check_off, …) |
skill/ | The AI playbook: SKILL.md (Claude-family Agent Skill) + prompt-pack.md (portable, any assistant) — served live at /skill + /prompt-pack |
backup/ | The nightly pg_dump sidecar, and the restore script you want to have rehearsed |
planning/ | Product plan and decisions log this POC implements |
Web app
The big-screen client, served by the API itself at /app — same origin as the
endpoints it calls, so there's no second host, no CORS, nothing extra to
deploy: if the server is up, the web app is too. Plain HTML/CSS/ES modules
with no build step and no external requests, in the same Sunday-market skin as
the site (dark mode included). It covers the whole loop: the plan, the
shopping list (staples check, "already have it", finish-the-shop), recipe
library with URL ingest, meals, the ingredient catalogue (aisles, staples,
⭐/💷 verdicts, duplicate merge — the tidy-up screens the phone doesn't have),
and settings (invites, API tokens, password, account deletion).
iOS app
make ios-build / make ios-test (needs Xcode + XcodeGen),
or open ios/Meals/Meals.xcodeproj after running xcodegen generate there.
The app ships pointing at https://meals.marcuslab.uk; put your own address in
the Server field on the sign-in screen, or launch the simulator build with
-serverURL http://localhost:8000 and use the seed's demo account. Check-offs
and quick adds work with no signal — the hard requirement from decision Q11:
interactions render instantly from a cached list, queue to disk, survive
relaunch, and replay in order (with idempotent client ids and id-remapping for
server-side merges) when connectivity returns.
make ios-screenshots regenerates the App Store screenshot set against a
throwaway seeded API and a throwaway simulator
(how). What's shipped, and what is only built,
is tracked in ios/CHANGELOG.md; submission material lives
in ios/AppStore/.
Trying the AI layer
meals.marcuslab.ukis my hosted service, and anyone can sign up to it. A household there is free within the limits the server publishes, and paying for a year lifts them (TERMS.md has the details). API tokens and/mcpare part of the free tier. Self-hosting is still free and fully supported: it's AGPL, a server you run has no household limits unless you set some, andmake devgets you the whole stack. The/skilland/prompt-packendpoints stay open to everyone, because they're documentation.
The MCP server ships with the deployment — any MCP-capable assistant connects
by URL, no local Python or repo checkout. The API serves it at /mcp on its
own origin, so a single container is enough; the separate mcp image exists
for deployments that would rather run it apart (this one does, see
docker-compose.yml), and MCP_ENABLED=false turns the
built-in one off. Either way it holds no credentials.
Create a personal API token (POST /auth/tokens, or use the seed's) and send
it as a bearer header:
claude mcp add --transport http meals https://your-meals-server.example/mcp \
--header "Authorization: Bearer meals_…"
The remote server holds no credentials of its own: each request's bearer
token is forwarded to the API, so every connecting client acts as themselves.
Any MCP client that can send a custom header works the same way, claude.ai
custom connectors included (they take a fixed Authorization header).
Signing in instead of pasting a token (OAuth). With OAUTH_ENABLED=true
the API is also the OAuth authorization server for its own /mcp, so a client
that speaks MCP authorization (a claude.ai custom connector, Claude Code) can
be given just the URL and will open a YAMP sign-in page: the person sees who is
asking and where they'll be sent back to, types their email and password, and
approves. Off by default; personal API tokens keep working either way. To turn
it on for your own server:
- Set
OAUTH_ENABLED=trueon the API. - If the MCP runs in its own container, set
MEALS_MCP_OAUTH=1on that container too (docker-compose.ymlpassesOAUTH_ENABLEDto both). The built-in/mcpfollows the API's setting by itself. - Make sure your proxy sends
/.well-known/*and/oauth/*to the API and passesX-Forwarded-Proto/X-Forwarded-Host(or the realHost): the metadata is served under whatever public origin the request came in on. - In claude.ai, add a custom connector with
https://your-server/mcpand choose Use Claude's published identity. Clients that only know dynamic client registration are served too.
Once it is on, /mcp answers a request without a valid token with a 401
rather than letting it reach the tools, so a health check that runs an MCP
handshake has to send a token. Changing your password disconnects every
signed-in assistant; decision Q27 in
planning/04-open-questions.md has the design.
Local fallback (stdio) — no deployment needed, runs from the repo:
{
"mcpServers": {
"meals": {
"command": "uv",
"args": ["run", "--project", "/path/to/meals/mcp", "python", "-m", "meals_mcp.server"],
"env": {
"MEALS_API_URL": "http://localhost:8000",
"MEALS_API_TOKEN": "meals_…"
}
}
}
}
Running it as its own service: MEALS_MCP_TRANSPORT=http serves streamable
HTTP at /mcp on 0.0.0.0:8000 (make dev exposes that container on
http://localhost:8100/mcp, and the same endpoint from the API itself on
http://localhost:8000/mcp).
The skill & prompt pack
The server publishes its own operating manual — grab it from the deployment, not a repo checkout, so it always matches the endpoints it describes:
/skill—SKILL.md, installable as a Claude-family Agent Skill./prompt-pack— portable instructions for any assistant, served with that deployment's base URL already filled in: paste into custom instructions, add your API token, and the REST API alone is enough (no MCP needed).
Both are unauthenticated on any instance, so you can read mine to see the shape of them (https://meals.marcuslab.uk/skill and https://meals.marcuslab.uk/prompt-pack). Give your assistant the ones from the server your household is on, though, since the pack embeds the base URL it was served from.
Both are unauthenticated, ship inside the backend image, and are advertised
from the API root (GET / returns a JSON landing for non-browser clients).
The repo copies in skill/ are the sources.
Version stamp. An installed skill or a pasted prompt pack is a snapshot
that never updates itself, so both carry a <!-- playbook-version: N --> marker
and the live surfaces publish the current number: GET /skill/version (also on
the root landing as playbook_version) and the MCP server's connection
instructions, which every client re-reads on connect. An assistant that sees a
higher number than its own copy knows to re-fetch and say so.
When the playbook's guidance changes, bump all four together — the stamps in
skill/SKILL.md and skill/prompt-pack.md
(the <!-- playbook-version: N --> marker and the "playbook vN" line each file
states in its prose), PLAYBOOK_VERSION in
mcp/meals_mcp/server.py, and the version + content
digest pinned in
backend/tests/integration/test_misc.py.
Tests fail if they drift — and the pinned digest is what makes that mean
something. A stamp only helps if it moves when the guidance does: without the
pin, new tools and new advice can ship under an unchanged number, so a stale
copy compares v1 to v1, sees no drift, and never learns what it is missing. The
digest hashes both documents with the version references normalised out, so
editing what the playbook says fails
test_guidance_changes_are_announced_by_a_version_bump until the version is
bumped; the failure message prints the new digest to paste in.
The quantity convention (decision Q2)
Every quantity is metric (g/kg/ml/l) or a count of a natural unit ("2 tins", "3 cloves"). No cups/oz/tbsp — clients convert first, and the API rejects violations with the exact conversion to apply. The backend merges exact-matching canonical units only.
Tests
make test # 620 backend tests (97% coverage) + 62 mcp tests — no Docker, no network
make ios-test # 141 XCTest tests: API decoding against captured fixtures, the offline sync engine, error mapping
The suite covers the unit convention, JSON-LD extraction (incl. @graph,
HowToSections, malformed scripts), ingredient-line parsing, auth + PATs +
rate limiting, password change + session revocation, recipe caching semantics,
and the full shopping-list engine
(merging, provenance, decrement-on-removal, ad-hoc survival, staples,
check-off/uncheck, archive, resync on meal/recipe edits).
CI
.github/workflows/ci.yml runs on every push to
main and every PR: make lint, make test, then the two things the local
suite can't see — alembic upgrade head + alembic check against real
Postgres (the suite builds its schema from metadata, so a missing or
SQLite-only migration is invisible to it), and a full docker compose smoke
that boots both images and re-runs the deploy's own checks (/healthz,
/client-config, /skill with {{API_URL}} substituted, /prompt-pack, an
MCP initialize handshake) before seeding demo data through the public API.
There is no iOS job — macOS runners are billed per minute even on public
repos, so make ios-build / make ios-test stay local for now.
Deployment notes
The stack is an API container + a remote-MCP container + Postgres, deployed here
on Docker Swarm behind Traefik with Let's Encrypt (see
docker-compose.yml for the shape). The MCP container is
routed at /mcp on the same host and
authenticates nothing itself — it forwards each caller's bearer token to the
API, which stays the single auth gate. Being internet-facing, auth is
mandatory everywhere except /healthz, and auth endpoints are rate-limited.
Registering creates a new household, so an open server never exposes an
existing one; REGISTRATION_ENABLED=false additionally stops new households
being created while still honouring invite codes, so closing a server doesn't
lock out your own family.
Leaving registration open is safer with three things this server does for you
(decision Q26). On a server with SMTP configured, a new account is emailed a
code to confirm its address, and until it does it can use its household freely
but cannot invite anyone or import a recipe from a URL, the two things that
reach outward (EMAIL_VERIFICATION_TTL_HOURS, 48 by default). Starting a
household is limited per client address per hour (SIGNUP_RATE_LIMIT_PER_HOUR,
5 by default, 0 for off), which joining one with an invite never is. And
python -m app.reaping from cron can clear out households that were made and
never used: set REAP_ABANDONED_AFTER_DAYS and it emails a household with one
member, nothing in it, no payment and no sign-in for that long, then deletes it
REAP_WARNING_DAYS (14) after the email unless somebody signs in. It is off
unless you set that, it never touches a household that has held anything, and
with no mail relay it warns nobody and so reaps nobody.
GET /household/export returns everything a household owns — recipes,
ingredients, meals, plans, cooked history, supermarkets and every shopping list
including archived ones — as one streamed JSON document. It is free on every
tier and always will be: taking your data elsewhere is not something a server
should be able to make difficult, and it is also the thing to run before a
migration. Import is deliberately not built yet; it is a bigger design question
(id collisions, ingredient folding) and it should not have held the export up.
Per-household limits exist and are off: LIMITS_PROFILE is unlimited
unless you change it, every household's tier is unlimited, and the
enforcement returns before it runs a query, so an install that sets nothing has
no caps and no idea any exist. If you want them — a server you let friends
onto, a box you'd rather not have somebody fill — LIMITS_PROFILE=hosted runs a
published table of numbers, LIMITS_OVERRIDES tunes any single one as JSON, and
DEFAULT_HOUSEHOLD_TIER says what a new registration starts on. MAX_HOUSEHOLDS
and MAX_USERS are the other axis and are also unset by default: they bound how
many families the box holds rather than what each one costs, so registration
answers 503 with a waitlist sentence instead of the machine finding its own
limit. Both sit on /metrics next to the counts they bound, which is how you
see "nearly full" before somebody is turned away.
If a deployment does charge, the entitlement is one row: python -m app.entitlements comps, extends, revokes and lists who is paid, and
python -m app.dunning from cron sends one email before an expiry and one
after. Lapsing is derived rather than written back, so it only ever stops a
household growing: nothing is deleted, everything already there stays
readable, the plan stays usable, the shopping list keeps working and the export
stays free. None of it applies to a household with no expiry, which is every
self-hosted one.
Payment itself is a webhook, and it is off unless a deployment sets both
BILLING_PROCESSOR and BILLING_WEBHOOK_SECRET — off meaning the route does
not exist, not that it exists and refuses. Starting a payment is a second switch
again (BILLING_API_KEY and BILLING_PRICE_ID): a server can be told about
payments while holding no key that could charge anybody, and until it does,
POST /billing/checkout is not there either. Nothing about any of this reaches
the iPhone app, which carries no price, no button and no link to one. Stripe Managed Payments, Paddle and
Lemon Squeezy are all supported because all three are merchants of record and
handle EU VAT; which one is a setting, not a rewrite. Every webhook is signature-verified, recorded once in
a ledger so a retry cannot grant a second year, and counted by outcome, because
the expensive failure here is the quiet one: somebody paying and not being
credited. Being over a
limit blocks only the writes that grow a household; nothing is deleted, nothing
becomes unreadable, and the shopping list is exempt in every case because the
iPhone app drains its offline queue through it. Whatever you set, GET /limits
publishes it: every resource with its cap, what is used and what is left, so an
assistant about to import two hundred recipes can ask first. On a server that
caps nothing it answers "unlimited" rather than 404, so no client ever has to
special-case its absence.
make deploy runs deploy/deploy.sh, which is not in this repo — a swarm
stack file is a description of somebody's specific hardware (node names, ingress
labels, host aliases, which box has the free disk), so it's kept out and
gitignored. docker-compose.yml is the honest reference
for the shape of the deployment, and it's what CI boots and smoke-tests.
Being untracked, deploy/ only exists in the checkout you put it in, so from a
git worktree there is nothing to run. make deploy therefore falls back to
the main worktree's copy of the script and passes the current tree as
MEALS_REPO_ROOT, which matters when that script is building rather than
pulling.
That deployment pulls the released images and deploys them by digest, which is worth copying if you run something similar:
- Deploy a digest, not a tag. A rebuilt
:latestleaves the service spec identical, sodocker stack deployreports success and rolls nothing. Resolve the tag torepo@sha256:…and the spec changes exactly when the image does. - Pull on the node that will run it, before deploying. Locally built images have no registry to pull from at all, so a task only starts where the image already is; pre-pulling also means a start-first rollout has nothing to wait for.
- Then check what is actually running (
docker service inspect … Image) rather than trusting that the deploy did anything.
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md. The decisions log records why things are the way they are (Q1–Q19), and code comments cite those numbers. Vulnerabilities go through SECURITY.md, not a public issue.
Licence
Copyright © 2026 Marcus Williams.
- Everything except
ios/— GNU AGPL-3.0. Self-host it, modify it, run it for your household, free and without asking; if you run a modified version as a network service, your users are entitled to its source. ios/— source-available, not open source. You may read it, build it and run it on your own devices, and contribute back, but not redistribute it or ship it to an app store.
The carve-out exists because the App Store's terms and the GPL family conflict,
which is what makes an AGPL iOS app undistributable there. Keeping ios/ under
its own licence is what lets the same code be published here and shipped through
TestFlight. Contributions to ios/ therefore need a CLA; everything else doesn't.