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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7255c9302c
commit
44de784f70
@@ -0,0 +1,75 @@
|
||||
"""Thin HTTP client around the Food Django REST API.
|
||||
|
||||
Pure HTTP — holds no Django import. Talks to the API over localhost using the
|
||||
`caine` DRF token (Authorization: Token <token>). One method per endpoint the
|
||||
MCP tools need; all errors surface as `FoodApiError` with a readable message.
|
||||
"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FoodApiError(Exception):
|
||||
"""The Django API returned a non-2xx response, or was unreachable."""
|
||||
|
||||
|
||||
class FoodClient:
|
||||
def __init__(self, base=None, token=None, http=None):
|
||||
base = (base or os.environ.get("FOOD_API_BASE", "http://127.0.0.1:8042/api")).rstrip("/")
|
||||
token = token or os.environ.get("FOOD_API_TOKEN", "")
|
||||
# `http` is injectable for tests (e.g. httpx.MockTransport).
|
||||
self._http = http or httpx.Client(
|
||||
base_url=base,
|
||||
headers={"Authorization": f"Token {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
# --- low level ---
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
try:
|
||||
resp = self._http.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as e:
|
||||
raise FoodApiError(f"could not reach the food API: {e}") from e
|
||||
if resp.status_code >= 400:
|
||||
raise FoodApiError(f"{resp.status_code}: {_safe_json(resp)}")
|
||||
return _safe_json(resp)
|
||||
|
||||
def _get(self, path, params=None):
|
||||
return self._request("GET", path, params=params)
|
||||
|
||||
def _post(self, path, json):
|
||||
return self._request("POST", path, json=json)
|
||||
|
||||
# --- endpoints (one per MCP tool need) ---
|
||||
|
||||
def pantry(self):
|
||||
return self._get("/pantry/")
|
||||
|
||||
def set_state(self, ingredient, state, location=None):
|
||||
body = {"ingredient": ingredient, "state": state}
|
||||
if location:
|
||||
body["location"] = location
|
||||
return self._post("/pantry/set-state/", body)
|
||||
|
||||
def bulk_add(self, items):
|
||||
return self._post("/bulk-pantry-add/", {"items": items})
|
||||
|
||||
def what_can_i_cook(self, servings=2):
|
||||
return self._get("/what-can-i-cook/", {"servings": servings})
|
||||
|
||||
def meta_recipes(self):
|
||||
return self._get("/meta-recipes/")
|
||||
|
||||
def log_cook(self, payload):
|
||||
return self._post("/log-cook/", payload)
|
||||
|
||||
def create_meta_recipe(self, payload):
|
||||
return self._post("/create-meta-recipe/", payload)
|
||||
|
||||
|
||||
def _safe_json(resp):
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return resp.text
|
||||
Reference in New Issue
Block a user