Add read-only Buzz client entrypoint

Expose the existing stdio server to a dedicated Buzz agent without granting mutation tools or leaking unrelated runtime credentials. Document the verified Hermes and Buzz client paths.

Co-authored-by: fredamn76 <fredrik.fallman@gmail.com>
Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
2026-07-31 10:35:34 +02:00
committed by fredamn76
parent c8775ac713
commit 242bb15641
6 changed files with 262 additions and 1 deletions
+52 -1
View File
@@ -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
+28
View File
@@ -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()
+1
View File
@@ -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"]
+79
View File
@@ -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
+79
View File
@@ -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
+23
View File
@@ -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": []}