summaryrefslogtreecommitdiff
path: root/mcp_server/server.py
diff options
context:
space:
mode:
authorTom Flux <tom@tomflux.xyz>2026-06-23 21:16:30 +0100
committerTom Flux <tom@tomflux.xyz>2026-06-23 21:16:30 +0100
commiteed90569f7dafa7eb9c8358c152efac1a49f0405 (patch)
treee8b6cbe4c63e243a79322a7238b986385ae7fb6f /mcp_server/server.py
parentd118726d3010d5b0133c7665fc20008c055f2757 (diff)
Phase 3b: FastMCP "ai service" for claude.ai cooking mode
A standalone MCP server (no Django import) exposing 7 tools over Streamable HTTP, backed by the Django REST API via the caine token. - mcp_server/: client.py (httpx wrapper over /api/), server.py (the tools + FastMCP app + main), __main__.py, tests.py. - Tools: get_pantry, set_item_state, add_to_pantry, what_can_i_cook, get_recipes, log_cook (suggests, never mutates), create_meta_recipe (brainstorm -> commit). Each description says when to call it. - deploy/food-mcp.service: systemd unit (own process, runs .venv python -m mcp_server, loads /var/lib/food/.env). - deploy/food.tomflux.xyz.nginx: current config + an authless /mcp/<secret>/ location proxying to 127.0.0.1:8765 (the URL secret is the credential; SSE-friendly buffering/timeout). - pyproject: [dependency-groups] mcp = [fastmcp, httpx]; deploy with `uv sync --group mcp`. Verified: 7 tools register on fastmcp 3.x, run() accepts transport/ host/port/path, 6 FoodClient unit tests pass (httpx MockTransport). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'mcp_server/server.py')
-rw-r--r--mcp_server/server.py168
1 files changed, 168 insertions, 0 deletions
diff --git a/mcp_server/server.py b/mcp_server/server.py
new file mode 100644
index 0000000..cc8fe4a
--- /dev/null
+++ b/mcp_server/server.py
@@ -0,0 +1,168 @@
+"""FastMCP tool definitions for the Food pantry app.
+
+Each tool maps to a Django API call via FoodClient and returns compact,
+Claude-friendly data (names + states, not raw API dumps). Tool descriptions
+state *when* to call them — current models reach for tools conservatively.
+
+Run with `python -m mcp_server` (see __main__.py). FastMCP 2.x.
+"""
+import os
+
+from fastmcp import FastMCP
+
+from .client import FoodClient, FoodApiError
+
+mcp = FastMCP("food-pantry")
+_api = FoodClient()
+
+VALID_STATES = ("in", "low", "out")
+
+
+def _err(e):
+ return {"error": str(e)}
+
+
+@mcp.tool
+def get_pantry(location: str | None = None, include_out: bool = False) -> dict:
+ """List what's in the pantry with each item's state (in / low / out). Call
+ this before suggesting meals or a shopping list so advice is grounded in
+ what's actually in stock. `location` optionally filters to fridge, cupboard,
+ or freezer. 'out' items are omitted unless `include_out` is true."""
+ try:
+ rows = _api.pantry()
+ except FoodApiError as e:
+ return _err(e)
+
+ items = []
+ counts = {"in": 0, "low": 0, "out": 0}
+ for r in rows:
+ state = r.get("state")
+ if state in counts:
+ counts[state] += 1
+ if location and r.get("location") != location:
+ continue
+ if not include_out and state == "out":
+ continue
+ items.append({
+ "name": r.get("ingredient_name"),
+ "state": state,
+ "location": r.get("location"),
+ "quantity": r.get("quantity"),
+ "unit": r.get("unit"),
+ "expiry": r.get("expiry_date"),
+ "staple": r.get("is_staple"),
+ })
+ return {"items": items, "counts": counts}
+
+
+@mcp.tool
+def set_item_state(ingredient: str, state: str, location: str | None = None) -> dict:
+ """Mark an ingredient In stock / running Low / Out. Call this when the user
+ says they used up, ran low on, or restocked something. `state` must be one
+ of 'in', 'low', 'out'. `location` is optional (fridge/cupboard/freezer)."""
+ if state not in VALID_STATES:
+ return {"error": f"state must be one of {list(VALID_STATES)}"}
+ try:
+ return _api.set_state(ingredient, state, location)
+ except FoodApiError as e:
+ return _err(e)
+
+
+@mcp.tool
+def add_to_pantry(items: list[dict]) -> dict:
+ """Add items the user bought. Each item is {name, location?, quantity?,
+ unit?} — e.g. {"name": "pork mince", "location": "fridge"}. Existing items
+ are restocked (set back to In) rather than duplicated."""
+ payload = [
+ {
+ "ingredient_name": it.get("name") or it.get("ingredient_name"),
+ "location": it.get("location", "fridge"),
+ "quantity": it.get("quantity"),
+ "unit": it.get("unit"),
+ }
+ for it in items
+ ]
+ try:
+ return _api.bulk_add(payload)
+ except FoodApiError as e:
+ return _err(e)
+
+
+@mcp.tool
+def what_can_i_cook(servings: int = 2) -> dict:
+ """Match the pantry against the meta-recipe templates. Returns each recipe
+ as ready / partial / missing with per-slot availability — use it to suggest
+ what the user can make right now."""
+ try:
+ return _api.what_can_i_cook(servings)
+ except FoodApiError as e:
+ return _err(e)
+
+
+@mcp.tool
+def get_recipes() -> dict:
+ """The meta-recipe templates with their swappable slot options. Use this to
+ suggest substitutions — each slot lists the ingredients that can fill it
+ (e.g. a protein slot accepts pork mince OR chicken)."""
+ try:
+ return {"meta_recipes": _api.meta_recipes()}
+ except FoodApiError as e:
+ return _err(e)
+
+
+@mcp.tool
+def log_cook(
+ meta_recipe_id: int | None = None,
+ recipe_id: int | None = None,
+ slot_choices: dict | None = None,
+ servings: int = 2,
+ rating: int | None = None,
+ notes: str = "",
+) -> dict:
+ """Record a cooked meal (and an optional 1-5 rating). Provide exactly one of
+ `meta_recipe_id` or `recipe_id`. Returns `used_ingredients` as SUGGESTIONS
+ only — the pantry is NOT changed. Confirm with the user, then call
+ `set_item_state` for each ingredient they agree to mark low or out."""
+ payload = {
+ "meta_recipe_id": meta_recipe_id,
+ "recipe_id": recipe_id,
+ "slot_choices": slot_choices or {},
+ "servings": servings,
+ "notes": notes,
+ }
+ if rating is not None:
+ payload["rating"] = rating
+ try:
+ return _api.log_cook(payload)
+ except FoodApiError as e:
+ return _err(e)
+
+
+@mcp.tool
+def create_meta_recipe(recipe: dict) -> dict:
+ """Save a meta-recipe the user brainstormed in the chat. `recipe` is the full
+ template:
+
+ {name, method, prep_time_mins?, cook_time_mins?, default_servings?,
+ gear_needed?, tags?,
+ slots: [{name, required?, max_choices?,
+ options: [{ingredient_name, quantity_per_serving, unit, tags?}]}],
+ base_ingredients: [{ingredient_name, quantity_per_serving, unit}]}
+
+ Pass an `id` to update an existing template (it rebuilds slots/bases).
+ Unknown ingredients are auto-created."""
+ try:
+ return _api.create_meta_recipe(recipe)
+ except FoodApiError as e:
+ return _err(e)
+
+
+def main():
+ host = os.environ.get("FOOD_MCP_HOST", "127.0.0.1")
+ port = int(os.environ.get("FOOD_MCP_PORT", "8765"))
+ # Streamable HTTP at the root path; nginx maps /mcp/<secret>/ -> here.
+ mcp.run(transport="http", host=host, port=port, path="/")
+
+
+if __name__ == "__main__":
+ main()