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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 19:48:07 +02:00
parent feb7c173d5
commit a0a3fb0eb0
4 changed files with 21 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.