diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fca029..ad3640b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -101,7 +101,7 @@ repos: - pytest - 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 - repo: https://github.com/PyCQA/bandit diff --git a/pyproject.toml b/pyproject.toml index 9b4e786..b958ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ screen-locker-status = "screen_locker.status_view:main" # 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"] [tool.setuptools.packages.find] # This repo also holds stronglift_replacement/ (an unrelated Flutter app, not diff --git a/requirements.txt b/requirements.txt index 1d241ab..8b48eed 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 pylint>=3.0.0 diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index 402c521..a3a6d6b 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -37,7 +37,8 @@ from pathlib import Path import sys from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer +from mcp.types import ToolAnnotations from screen_locker._compliance_state import ( has_logged_today, @@ -62,7 +63,18 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -mcp = FastMCP("screen-locker") +mcp = MCPServer("screen-locker") + +# Every tool here only reads local state: the workout log, the sick history +# and the clock. Saying so lets a client run them without asking -- and the +# day one of them stops being read-only, this annotation is the thing that +# has to change with it. (See the section banner below: "never mutate" was +# already the rule; this is the machine-readable half of it.) +_READS_ONLY = ToolAnnotations( + read_only_hint=True, + idempotent_hint=True, + open_world_hint=False, +) # The workout log lives beside the package, same default as # ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a @@ -75,7 +87,7 @@ _LOG_FILE = Path(__file__).resolve().parent / "workout_log.json" # ────────────────────────────────────────────────────────────── -@mcp.tool() +@mcp.tool(title="Workout gate status", annotations=_READS_ONLY) def get_status() -> dict[str, Any]: """Return the full read-only status snapshot. @@ -87,7 +99,7 @@ def get_status() -> dict[str, Any]: return asdict(gather_status()) -@mcp.tool() +@mcp.tool(title="One-line status summary", annotations=_READS_ONLY) def get_summary() -> dict[str, Any]: """Return the one-line i3blocks summary plus the compliance-state word. @@ -102,7 +114,7 @@ def get_summary() -> dict[str, Any]: } -@mcp.tool() +@mcp.tool(title="Why the screen is locked", annotations=_READS_ONLY) def explain_lock() -> dict[str, Any]: """Return why the screen is or is not being locked right now. @@ -114,7 +126,7 @@ def explain_lock() -> dict[str, Any]: return asdict(gather_status().lock_explanation) -@mcp.tool() +@mcp.tool(title="Lock-decision predicates", annotations=_READS_ONLY) def get_flags() -> dict[str, bool]: """Return the individual boolean lock-decision predicates for today.