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:
@@ -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."""
|
||||
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": []}
|
||||
|
||||
Reference in New Issue
Block a user