diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14fcfc7..d2c7a17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,7 +103,7 @@ repos: - requests - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync - - mcp>=1.9,<2 + - mcp>=2,<3 - pydantic # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. diff --git a/diet_guard/_mcp.py b/diet_guard/_mcp.py index 0219419..44e7840 100644 --- a/diet_guard/_mcp.py +++ b/diet_guard/_mcp.py @@ -37,7 +37,8 @@ import logging import sys from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer +from mcp.types import ToolAnnotations from pydantic import BaseModel from diet_guard._budget import BudgetError @@ -65,7 +66,26 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -mcp = FastMCP("diet-guard") +mcp = MCPServer("diet-guard") + +# Machine-readable versions of what the docstrings already say, so a client +# can decide what to run unattended. The day a "read" tool starts writing, +# these annotations are what has to change with it. +_READS_ONLY = ToolAnnotations( + read_only_hint=True, + idempotent_hint=True, + open_world_hint=False, +) +# log_meal is the one tool that writes. It appends and never rewrites or +# deletes, so it is not destructive; it is not idempotent either, because a +# second confirmed call logs a second meal. It reaches Open Food Facts when +# macros are not supplied, which is exactly what open_world means. +_APPENDS = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, +) def _entry_view(entry: dict[str, object]) -> dict[str, Any]: @@ -98,7 +118,7 @@ def _entry_view(entry: dict[str, object]) -> dict[str, Any]: # ────────────────────────────────────────────────────────────── -@mcp.tool() +@mcp.tool(title="Today's intake status", annotations=_READS_ONLY) def get_status() -> dict[str, Any]: """Return today's intake status without ever revealing the daily budget. @@ -126,7 +146,7 @@ def get_status() -> dict[str, Any]: } -@mcp.tool() +@mcp.tool(title="Meals logged today", annotations=_READS_ONLY) def list_today() -> dict[str, Any]: """List today's logged meals (valid entries only), newest last. @@ -141,7 +161,7 @@ def list_today() -> dict[str, Any]: } -@mcp.tool() +@mcp.tool(title="Meal slots for the day", annotations=_READS_ONLY) def get_slots() -> dict[str, Any]: """Return the day's fixed meal slots and which one is current. @@ -177,7 +197,7 @@ class Macros(BaseModel): fat: float = 0.0 -@mcp.tool() +@mcp.tool(title="Log a meal (gated write)", annotations=_APPENDS) def log_meal( description: str, grams: float | None = None, diff --git a/pyproject.toml b/pyproject.toml index b6768f9..34e0db7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ # so the systemd/CLI system-python path stays dependency-light and never has # to import the MCP SDK. [project.optional-dependencies] -mcp = ["mcp>=1.9,<2"] +mcp = ["mcp>=2,<3"] # Without this, setuptools' flat-layout auto-discovery treats the Flutter # app/ directory as a second top-level package and refuses to build. diff --git a/requirements.txt b/requirements.txt index 6d8ecc9..4f1950c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ codespell>=2.2.0 coverage>=7.4.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock -mcp>=1.9,<2 +mcp>=2,<3 mypy>=1.8.0 pre-commit>=3.6.0 pydantic>=2