diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 0729011..6ef44ce 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -1,6 +1,7 @@ """Small, confirmation-friendly MCP facade over the Mealie API.""" from __future__ import annotations +import asyncio import html import json import os @@ -38,16 +39,17 @@ RESOURCE_DIR = Path(__file__).parent / "resources" mcp = MCPServer("mealie", version="0.1.0") -WRITE_TOOL_NAMES = frozenset( +READ_ONLY_TOOL_NAMES = frozenset( { - "import_recipe_url", - "import_recipe_text", - "import_recipe_image", - "patch_recipe", - "delete_recipe", - "parse_ingredients", - "set_cover_image", - "shopping_list_add", + "check_auth", + "find_by_source_url", + "get_recipe", + "list_organizers", + "resolve_foods", + "scale_ingredients", + "search_recipes", + "suggest_recipes", + "verify_recipe", } ) @@ -762,10 +764,11 @@ def main() -> None: def _configure_read_only( server: MCPServer, - write_tool_names: frozenset[str] = WRITE_TOOL_NAMES, + allowed_tool_names: frozenset[str] = READ_ONLY_TOOL_NAMES, ) -> None: - """Remove mutating tools before exposing the server to a read-only client.""" - for name in sorted(write_tool_names): + """Expose only explicitly approved tools to a read-only client.""" + 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) diff --git a/tests/test_server_contracts.py b/tests/test_server_contracts.py index 0406ec3..7ab5a80 100644 --- a/tests/test_server_contracts.py +++ b/tests/test_server_contracts.py @@ -31,19 +31,35 @@ def test_read_only_server_removes_all_mutating_tools(): def search_recipes(query: str) -> list[str]: return [query] - def mutating_tool(value: str) -> str: + @test_server.tool() + def patch_recipe(value: str) -> str: return value - for name in server.WRITE_TOOL_NAMES: - test_server.tool(name=name)(mutating_tool) + @test_server.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())} 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: def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch): recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}