From a0a3fb0eb0b986bac34fdd4bf26342fd0ece38ce Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Mon, 3 Aug 2026 19:48:07 +0200 Subject: [PATCH] Port the MCP server to mcp 2.x, and say what each tool is allowed to do mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its name, signature and returned shape. Verified against a clean 2.0.0 venv: all four tools list, and the whole suite -- including test_mcp.py, which could not even import while the dependency was broken -- passes at 100% coverage, 901 tests. Two things the port buys, neither of which existed in 1.x: * Tool annotations. The section banner here already said "state-only; never expose secrets, never mutate"; now the protocol says it too, as read-only, idempotent and closed-world hints, which is what lets a client run these without asking first. The day one of them starts writing, the annotation is what has to change with it. * Structured output. 2.x derives an output schema from the return types and sends structured content alongside the text, so a caller no longer re-parses JSON we already had. No code change -- it falls out of the type hints that were already there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- screen_locker/_mcp.py | 24 ++++++++++++++++++------ 4 files changed, 21 insertions(+), 9 deletions(-) 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.