No description
  • C# 99.1%
  • Dockerfile 0.5%
  • Python 0.3%
Find a file
TJC f5690acc69 Merge pull request #27 from TimCane/docs/plan-recipe-links-and-grafana
docs(plan): recipe links on the MCP surface and a Grafana stack

Co-authored-by: AI <ai@noreply.git.tjc.fyi>
2026-07-21 22:40:36 +01:00
.claude chore(claude): enforce ascii-only on writes 2026-07-19 10:36:05 +00:00
.config feat(infrastructure): add scrape schema and raw document store 2026-07-19 10:55:31 +00:00
.devcontainer fix(devcontainer): name the dev stack apart from the deploy stack 2026-07-19 14:35:10 +00:00
.github/workflows ci: build, test and ascii checks on pull requests 2026-07-19 10:44:16 +00:00
src feat(mcp): rate limit callers with a fixed window per address 2026-07-20 20:35:18 +00:00
tests test(audit): pin session identity, capture and retention 2026-07-20 20:41:10 +00:00
.editorconfig feat: add solution skeleton 2026-07-19 10:44:16 +00:00
.env.example feat(scraper): put the hangfire dashboard behind basic auth 2026-07-19 17:31:51 +00:00
.gitignore feat: add the deployment stack behind the vpn 2026-07-19 14:27:42 +00:00
CLAUDE.md docs: add design plan and project conventions 2026-07-19 10:36:05 +00:00
Directory.Build.props feat: add solution skeleton 2026-07-19 10:44:16 +00:00
Directory.Packages.props test(mcp): protocol round-trip against the in-process server 2026-07-20 17:13:06 +00:00
docker-compose.yaml fix(deploy): bind the dashboard domain to gluetun not the scraper 2026-07-19 17:41:44 +00:00
Mealplan.slnx feat: add solution skeleton 2026-07-19 10:44:16 +00:00
PLAN.md docs(plan): recipe links on the MCP surface and a Grafana stack 2026-07-21 21:05:43 +00:00
README.md docs(readme): describe the usage audit and the rate limit 2026-07-20 20:41:28 +00:00

Mealplan MCP

An MCP server that serves recipes scraped from HelloFresh and Gousto, so an AI assistant can build a meal plan from real recipe data.

See PLAN.md for the design.

Getting started

Open the repo in VS Code and reopen in the devcontainer. Postgres comes up alongside it and post-create.sh restores packages.

dotnet build
dotnet test

Integration tests bring up their own Postgres with Testcontainers and never touch the network; normalisers run against payloads captured from the source APIs.

Connection string for local development:

Host=db;Port=5432;Database=mealplanmcp;Username=mealplanmcp;Password=mealplanmcp

Ports

Port Service
5205 MCP server (/mcp, /health)
5206 Scraper: Hangfire dashboard (/jobs), /health
5432 Postgres

Running the stack

cp .env.example .env   # fill in WireGuard and Postgres values
docker compose up -d --build

Scraper egress goes through a gluetun container holding the ProtonVPN config. The scraper shares gluetun's network namespace, so if the tunnel drops it has no network at all rather than falling back to your real IP. The MCP server is on the normal bridge network and never touches the VPN.

Crawls then run themselves. A source with no completed crawl is queued immediately, so a fresh deployment fills itself rather than serving nothing until its scheduled night - HelloFresh crawls Sunday 03:00 UTC and Gousto Wednesday 03:00 UTC, so the wait would otherwise be up to a week. A redeploy against an existing database starts nothing, because the sources already have successful runs. Set Sources__<slug>__CrawlOnStartup=false to deploy quiet.

Normalising runs hourly per source, independently of crawling. Watch both on the Hangfire dashboard at /jobs, which is behind basic auth: JOBS_USER and JOBS_PASSWORD from .env. Deployed it is routed by the Coolify proxy and is not published on the host; locally it is http://localhost:5206/jobs.

Day to day development and the test suite need neither the VPN nor credentials.

First run

Before letting a scheduled crawl loose on a few thousand recipes, run a capped one and check what lands:

docker compose run --rm scraper crawl gousto --max 5
docker compose run --rm scraper normalize gousto

--max counts documents, not recipes: a Gousto crawl alternates list pages and recipe details, so 5 covers a page plus its first few recipes. Omit --max for a full pass. The same works for hellofresh.

If HelloFresh returns 403, the VPN exit is the problem, not the code. Cloudflare blocks some ProtonVPN IPs outright - "Sorry, you have been blocked", with no challenge to solve, so a headless browser does not help. Reconnect for a different exit and try again:

docker compose restart gluetun
docker compose logs gluetun | grep -i "public ip"

Check it arrived:

docker compose exec db psql -U mealplanmcp -c 'SELECT source, count(*) FROM scrape.document GROUP BY source'

Connecting an MCP client

The server speaks streamable HTTP at http://localhost:5205/mcp. For Claude Code:

claude mcp add --transport http mealplan http://localhost:5205/mcp

The tool descriptions served over MCP are canonical; this table is for humans browsing the repo.

Tool Returns
search_recipes A page of summaries with the per-portion nutrition panel, rating and allergens split into contains and may-contain-traces. Filters: free text (name, description, ingredients), sources, portions, cuisines, tags, allergens (traces excluded too unless excludeTraces is false), must-contain and disliked ingredients, prep and total time, nutrient ranges, minimum rating. Sorts by name, rating, kcal, or seeded random.
get_recipe Full detail at one portion count: ingredients, steps, utensils, nutrition, serving size, offered portion counts, website URL. A wrong portion count fails naming the counts that work.
list_allergens Allergen slugs and display names per source with contains and traces counts. The authoritative input for excludeAllergens; traceCount is 0 where hasTraceAllergens is false - unknown, not none.
list_cuisines Cuisine slugs, names and recipe counts per source.
list_tags Tag slugs, names and recipe counts per source. Tokens differ per source; feed them to the tags filter.
search_ingredients Text-filtered ingredient catalogue: what each source calls things, with family where published. Resolves names for includeIngredients.
get_shopping_list Flat ingredient rows for up to 14 chosen recipes at chosen portion counts, tagged by recipe. Pantry rows are staples the box will not contain. Nothing is merged across recipes or sources - the caller combines duplicates.
list_sources One row per source: recipe count and capability flags.
get_scrape_status Last crawl and pending-normalisation counts per source.

The server also ships three MCP prompts - plan_week, find_recipe and whats_available - that encode the safe flow through the tools: resolve allergen slugs before filtering, leave traces excluded, seed the random sort with the week number, finish with the shopping list. Their allergen, cuisine, source and dislike arguments autocomplete from the live vocabularies.

Call list_sources first. It reports what each source can and cannot say - most importantly hasIngredientQuantities, which is false for Gousto, because Gousto ships boxes and publishes no amounts. An absent quantity there means "not published", not "none needed". Likewise hasTraceAllergens is false for Gousto: an empty traceAllergens there means "unknown", not "none". The same rule runs through every field: null is "not published", and filters exclude recipes missing the filtered value.

Usage audit

Every tool and prompt call lands in audit.tool_call, with one audit.session row per MCP session. This is analytics, not a security trail: rows are written in the background and dropped rather than allowed to slow or fail a call. Read it with SQL - there is no dashboard and no tool.

docker compose exec db psql -U mealplanmcp -c \
  "SELECT name, count(*), count(*) FILTER (WHERE result_count = 0) AS empty
   FROM audit.tool_call GROUP BY name ORDER BY 2 DESC"

There is no auth, so "who" is only what the transport provides: the session id, the client name and version from the handshake, and the caller's address hashed with a salt that rotates daily and is never stored. Within a day those hashes group one caller; across days they cannot be linked at all. Arguments are stored verbatim on that basis. Rows are swept after 90 days (Audit:RetentionDays).

Callers are rate limited to 120 requests a minute per address (RateLimit:PermitsPerMinute) - clear of any real agent, and a cap on a runaway loop.

Adding a recipe source

Add a project under src/Mealplan.Infrastructure.<Name>/ implementing ISourceCrawler, ISourceNormalizer, ISourceSchema and ISourceModule. It is discovered by assembly scan; no host, migration or view definition needs changing. See Mealplan.Infrastructure.Gousto for the shape.