Enforce an explicit read-only tool allowlist

Remove every tool that is not explicitly approved for the private Buzz agent. This keeps future mutating tools closed by default instead of relying on a denylist that must be updated manually.

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:48:15 +02:00
committed by fredamn76
parent 242bb15641
commit e46c327875
2 changed files with 35 additions and 16 deletions
+15 -12
View File
@@ -1,6 +1,7 @@
"""Small, confirmation-friendly MCP facade over the Mealie API.""" """Small, confirmation-friendly MCP facade over the Mealie API."""
from __future__ import annotations from __future__ import annotations
import asyncio
import html import html
import json import json
import os import os
@@ -38,16 +39,17 @@ RESOURCE_DIR = Path(__file__).parent / "resources"
mcp = MCPServer("mealie", version="0.1.0") mcp = MCPServer("mealie", version="0.1.0")
WRITE_TOOL_NAMES = frozenset( READ_ONLY_TOOL_NAMES = frozenset(
{ {
"import_recipe_url", "check_auth",
"import_recipe_text", "find_by_source_url",
"import_recipe_image", "get_recipe",
"patch_recipe", "list_organizers",
"delete_recipe", "resolve_foods",
"parse_ingredients", "scale_ingredients",
"set_cover_image", "search_recipes",
"shopping_list_add", "suggest_recipes",
"verify_recipe",
} }
) )
@@ -762,10 +764,11 @@ def main() -> None:
def _configure_read_only( def _configure_read_only(
server: MCPServer, server: MCPServer,
write_tool_names: frozenset[str] = WRITE_TOOL_NAMES, allowed_tool_names: frozenset[str] = READ_ONLY_TOOL_NAMES,
) -> None: ) -> None:
"""Remove mutating tools before exposing the server to a read-only client.""" """Expose only explicitly approved tools to a read-only client."""
for name in sorted(write_tool_names): registered_tool_names = {tool.name for tool in asyncio.run(server.list_tools())}
for name in sorted(registered_tool_names - allowed_tool_names):
server.remove_tool(name) server.remove_tool(name)
+20 -4
View File
@@ -31,19 +31,35 @@ def test_read_only_server_removes_all_mutating_tools():
def search_recipes(query: str) -> list[str]: def search_recipes(query: str) -> list[str]:
return [query] return [query]
def mutating_tool(value: str) -> str: @test_server.tool()
def patch_recipe(value: str) -> str:
return value return value
for name in server.WRITE_TOOL_NAMES: @test_server.tool()
test_server.tool(name=name)(mutating_tool) def future_mutating_tool(value: str) -> str:
return value
server._configure_read_only(test_server) server._configure_read_only(test_server, frozenset({"search_recipes"}))
tool_names = {tool.name for tool in asyncio.run(test_server.list_tools())} tool_names = {tool.name for tool in asyncio.run(test_server.list_tools())}
assert tool_names == {"search_recipes"} assert tool_names == {"search_recipes"}
def test_read_only_tool_allowlist_is_explicit():
assert server.READ_ONLY_TOOL_NAMES == {
"check_auth",
"find_by_source_url",
"get_recipe",
"list_organizers",
"resolve_foods",
"scale_ingredients",
"search_recipes",
"suggest_recipes",
"verify_recipe",
}
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": []}