diff --git a/README.md b/README.md index 3b449c8..dbf1431 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,57 @@ Create the Mealie token at `/user/profile/api-tokens`. Home Assistant is optional and isolated: without `HA_BASE_URL` and `HA_TOKEN` everything except `shopping_list_add` works normally. +## Client integration + +### Hermes + +Hermes runs this server over stdio. The credential stays in +`~/.hermes/.env`; `config.yaml` contains only environment placeholders. + +```bash +hermes mcp add mealie \ + --command /home/fredrik/.local/bin/uv \ + --connect-timeout 60 \ + --env 'MEALIE_API_TOKEN=${MEALIE_API_TOKEN}' \ + 'MEALIE_BASE_URL=${MEALIE_BASE_URL}' \ + 'MEALIE_USER_AGENT=Mealie-MCP/0.1' \ + --args --directory /home/fredrik/.buzz/REPOS/mealie-mcp run mealie-mcp + +hermes config set mcp_discovery_timeout 10 +hermes mcp test mealie +``` + +The normal `hermes chat` client and the long-running gateway wait for MCP +discovery. On the Hermes version used for the P1 acceptance test, +`hermes -z` could snapshot its tools before a slower stdio server finished +discovery; use the normal chat/gateway path for this server until that +one-shot startup issue is fixed. + +### Buzz managed agent + +Buzz's managed-agent harness accepts one per-agent MCP executable. Set the +dedicated Recept agent's MCP command to this read-only launcher: + +```text +/home/fredrik/.buzz/REPOS/mealie-mcp/scripts/run-mealie-mcp-read-only-for-buzz +``` + +It contains search, read, scaling, organizer, duplicate-check, suggestion, and +verification tools, but no import, patch, delete, ingredient-parse, image, or +shopping-list tools. Switch to the full `mealie-mcp` entry point only after the +separate write acceptance test has passed. + +The launcher reads only `MEALIE_API_TOKEN`, `MEALIE_BASE_URL`, and +`MEALIE_USER_AGENT` from `~/.hermes/.env`, then starts the same stdio server +with a clean environment. It does not place the token in the agent prompt, +agent definition, command line, or Codex configuration. + +Keep this configuration agent-specific. Adding Mealie globally to +`~/.codex/config.toml` would make the tools available to every Codex-based +Buzz agent on the host, which is broader access than the private Recept agent +needs. No HTTP transport is required while the agent and server run on the +same machine. + ## Tools **Read** @@ -105,7 +156,7 @@ file tools; folding it in would make this server two things at once. ## Tests ```bash -python -m pytest +uv run --extra dev python -m pytest ``` The suite covers the verified failure modes: Cloudflare 1010 vs 401, decimal-comma diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 3d468e1..0729011 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -38,6 +38,19 @@ RESOURCE_DIR = Path(__file__).parent / "resources" mcp = MCPServer("mealie", version="0.1.0") +WRITE_TOOL_NAMES = frozenset( + { + "import_recipe_url", + "import_recipe_text", + "import_recipe_image", + "patch_recipe", + "delete_recipe", + "parse_ingredients", + "set_cover_image", + "shopping_list_add", + } +) + class MealieAuthError(RuntimeError): """The Mealie token is missing, expired, or wrong (HTTP 401).""" @@ -745,3 +758,18 @@ instructions, and units (`msk`, `tsk`, `dl`, `krm`, `st`, `klyfta`). def main() -> None: mcp.run() + + +def _configure_read_only( + server: MCPServer, + write_tool_names: frozenset[str] = WRITE_TOOL_NAMES, +) -> None: + """Remove mutating tools before exposing the server to a read-only client.""" + for name in sorted(write_tool_names): + server.remove_tool(name) + + +def main_read_only() -> None: + """Run the same server without import, patch, delete, parse, image, or HA writes.""" + _configure_read_only(mcp) + mcp.run() diff --git a/pyproject.toml b/pyproject.toml index 020e653..3d26762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dev = ["pytest>=8.0"] [project.scripts] mealie-mcp = "mealie_mcp.server:main" +mealie-mcp-read-only = "mealie_mcp.server:main_read_only" [build-system] requires = ["hatchling"] diff --git a/scripts/run-mealie-mcp-read-only-for-buzz b/scripts/run-mealie-mcp-read-only-for-buzz new file mode 100755 index 0000000..2ea4a99 --- /dev/null +++ b/scripts/run-mealie-mcp-read-only-for-buzz @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Buzz's managed-agent harness accepts one MCP executable per agent. Keep the +# Mealie credential out of the agent definition by loading only the three +# variables this server understands from the existing Hermes dotenv file. +mealie_env_file="${MEALIE_ENV_FILE:-${HOME}/.hermes/.env}" +mealie_repo_dir="${MEALIE_MCP_REPO_DIR:-/home/fredrik/.buzz/REPOS/mealie-mcp}" +mealie_uv_bin="${MEALIE_UV_BIN:-/home/fredrik/.local/bin/uv}" + +mealie_token="${MEALIE_API_TOKEN:-}" +mealie_base_url="${MEALIE_BASE_URL:-}" +mealie_user_agent="${MEALIE_USER_AGENT:-}" + +clean_dotenv_value() { + local value="$1" + local first + local last + + value="${value%$'\r'}" + if (( ${#value} >= 2 )); then + first="${value:0:1}" + last="${value: -1}" + if [[ "$first" == "$last" && ( "$first" == "'" || "$first" == '"' ) ]]; then + value="${value:1:${#value}-2}" + fi + fi + REPLY="$value" +} + +if [[ -r "$mealie_env_file" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + MEALIE_API_TOKEN=*) + if [[ -z "$mealie_token" ]]; then + clean_dotenv_value "${line#*=}" + mealie_token="$REPLY" + fi + ;; + MEALIE_BASE_URL=*) + if [[ -z "$mealie_base_url" ]]; then + clean_dotenv_value "${line#*=}" + mealie_base_url="$REPLY" + fi + ;; + MEALIE_USER_AGENT=*) + if [[ -z "$mealie_user_agent" ]]; then + clean_dotenv_value "${line#*=}" + mealie_user_agent="$REPLY" + fi + ;; + esac + done < "$mealie_env_file" +fi + +if [[ -z "$mealie_token" ]]; then + printf 'MEALIE_API_TOKEN is not set and was not found in %s\n' "$mealie_env_file" >&2 + exit 1 +fi + +mealie_base_url="${mealie_base_url:-https://recept.famfallman.com}" +mealie_user_agent="${mealie_user_agent:-Mealie-MCP/0.1}" + +# Do not forward the rest of the Desktop/Hermes environment to the MCP +# subprocess. In particular, unrelated provider and platform tokens must not +# become visible to this server. Export after sanitizing so the token is never +# passed as a command-line argument to an intermediate `env` process. +while IFS= read -r variable_name; do + case "$variable_name" in + HOME | PATH | LANG) ;; + *) unset "$variable_name" ;; + esac +done < <(compgen -e) + +export MEALIE_API_TOKEN="$mealie_token" +export MEALIE_BASE_URL="$mealie_base_url" +export MEALIE_USER_AGENT="$mealie_user_agent" + +exec "$mealie_uv_bin" --directory "$mealie_repo_dir" run mealie-mcp-read-only diff --git a/tests/test_client_launcher.py b/tests/test_client_launcher.py new file mode 100644 index 0000000..5734de3 --- /dev/null +++ b/tests/test_client_launcher.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +LAUNCHER = ( + Path(__file__).parents[1] / "scripts" / "run-mealie-mcp-read-only-for-buzz" +) + + +def _fake_uv(tmp_path: Path) -> Path: + executable = tmp_path / "fake-uv" + executable.write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' \"${MEALIE_API_TOKEN}|${MEALIE_BASE_URL}|" + "${MEALIE_USER_AGENT}|${UNRELATED_SECRET-unset}|$*\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + return executable + + +def test_launcher_reads_only_mealie_values_and_clears_unrelated_secrets( + tmp_path: Path, +) -> None: + env_file = tmp_path / "hermes.env" + env_file.write_text( + "OTHER_PROVIDER_TOKEN=must-not-leak\n" + "MEALIE_API_TOKEN='test-token'\n" + "MEALIE_BASE_URL=https://mealie.example\n" + 'MEALIE_USER_AGENT="Test-Agent/1.0"\n', + encoding="utf-8", + ) + env = { + **os.environ, + "MEALIE_ENV_FILE": str(env_file), + "MEALIE_MCP_REPO_DIR": "/tmp/test-repo", + "MEALIE_UV_BIN": str(_fake_uv(tmp_path)), + "UNRELATED_SECRET": "must-not-leak", + } + + result = subprocess.run( + [str(LAUNCHER)], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == ( + "test-token|https://mealie.example|Test-Agent/1.0|unset|" + "--directory /tmp/test-repo run mealie-mcp-read-only" + ) + assert "must-not-leak" not in result.stdout + + +def test_launcher_fails_without_mealie_token(tmp_path: Path) -> None: + env_file = tmp_path / "empty.env" + env_file.write_text("MEALIE_BASE_URL=https://mealie.example\n", encoding="utf-8") + env = { + "HOME": str(tmp_path), + "PATH": os.environ["PATH"], + "MEALIE_ENV_FILE": str(env_file), + "MEALIE_UV_BIN": str(_fake_uv(tmp_path)), + } + + result = subprocess.run( + [str(LAUNCHER)], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 1 + assert "MEALIE_API_TOKEN is not set" in result.stderr diff --git a/tests/test_server_contracts.py b/tests/test_server_contracts.py index b3f59b3..0406ec3 100644 --- a/tests/test_server_contracts.py +++ b/tests/test_server_contracts.py @@ -1,8 +1,11 @@ """Boundary contracts for the verified Mealie instance quirks.""" from __future__ import annotations +import asyncio + import httpx import pytest +from mcp.server.mcpserver import MCPServer from mealie_mcp import server @@ -21,6 +24,26 @@ class TestAuthenticationFailures: server._raise_for_status(response(403, "error code: 1010")) +def test_read_only_server_removes_all_mutating_tools(): + test_server = MCPServer("test") + + @test_server.tool() + def search_recipes(query: str) -> list[str]: + return [query] + + def mutating_tool(value: str) -> str: + return value + + for name in server.WRITE_TOOL_NAMES: + test_server.tool(name=name)(mutating_tool) + + server._configure_read_only(test_server) + + tool_names = {tool.name for tool in asyncio.run(test_server.list_tools())} + + assert tool_names == {"search_recipes"} + + class TestUrlImportResponse: def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch): recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}