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:
@@ -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`
|
Home Assistant is optional and isolated: without `HA_BASE_URL` and `HA_TOKEN`
|
||||||
everything except `shopping_list_add` works normally.
|
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
|
## Tools
|
||||||
|
|
||||||
**Read**
|
**Read**
|
||||||
@@ -105,7 +156,7 @@ file tools; folding it in would make this server two things at once.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pytest
|
uv run --extra dev python -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers the verified failure modes: Cloudflare 1010 vs 401, decimal-comma
|
The suite covers the verified failure modes: Cloudflare 1010 vs 401, decimal-comma
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ RESOURCE_DIR = Path(__file__).parent / "resources"
|
|||||||
|
|
||||||
mcp = MCPServer("mealie", version="0.1.0")
|
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):
|
class MealieAuthError(RuntimeError):
|
||||||
"""The Mealie token is missing, expired, or wrong (HTTP 401)."""
|
"""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:
|
def main() -> None:
|
||||||
mcp.run()
|
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()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dev = ["pytest>=8.0"]
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mealie-mcp = "mealie_mcp.server:main"
|
mealie-mcp = "mealie_mcp.server:main"
|
||||||
|
mealie-mcp-read-only = "mealie_mcp.server:main_read_only"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
Executable
+79
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Boundary contracts for the verified Mealie instance quirks."""
|
"""Boundary contracts for the verified Mealie instance quirks."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
from mealie_mcp import server
|
from mealie_mcp import server
|
||||||
|
|
||||||
@@ -21,6 +24,26 @@ class TestAuthenticationFailures:
|
|||||||
server._raise_for_status(response(403, "error code: 1010"))
|
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:
|
class TestUrlImportResponse:
|
||||||
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
|
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
|
||||||
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
|
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
|
||||||
|
|||||||
Reference in New Issue
Block a user