diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..340b0f4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "screen-locker": { + "type": "stdio", + "command": "${HOME}/.venvs/screen-locker-mcp/bin/python", + "args": ["-m", "screen_locker._mcp"], + "env": {} + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..80c5e22 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# screen-locker + +Tkinter/systemd screen locker with workout tracking, sick-day management, and +wake-alarm integration. It enforces a workout cadence by locking the screen +until a RunnerUp-verified (or explicitly justified) workout is logged. + +## MCP server (Claude Code integration) + +screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so +Claude Code and its subagents can query workout compliance and the +lock-decision state through typed tools — without shelling out to the +`screen-locker-status` CLI or opening the Tk window. + +- **Read-only tools:** `get_status` (full status snapshot), `get_summary` + (one-line i3blocks summary + `ok`/`warn`/`lock` state word), `explain_lock` + (why the screen is / isn't locked right now), `get_flags` (the individual + boolean lock-decision predicates for today). +- There are **no write/action tools**, by design. Nothing here logs a workout + or mutates state — workouts are logged only from RunnerUp-verified TCX data, + never from a caller's claim. No tool exposes the sync token or any HMAC key. + +The `mcp` SDK is an optional dependency (`pip install -e '.[mcp]'`), kept out of +the CLI/systemd system-python path. One-time setup of the dedicated venv that +Claude Code spawns: + +```bash +./scripts/setup_mcp.sh +``` + +Registration lives in the checked-in [`.mcp.json`](./.mcp.json) (project scope). +Restart Claude Code in this repo and approve the project MCP-server prompt for it +to load. Verify with `claude mcp list`. diff --git a/pyproject.toml b/pyproject.toml index 12b7221..c8e257f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,14 @@ dependencies = [ [project.scripts] screen-locker-status = "screen_locker.status_view:main" +# Optional extras. The `mcp` group is ONLY needed by the read-only MCP server +# (screen_locker._mcp), which Claude Code spawns via a dedicated venv (see +# scripts/setup_mcp.sh / .mcp.json). It is deliberately NOT a core dependency +# 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"] + [tool.setuptools.packages.find] # This repo also holds stronglift_replacement/ (an unrelated Flutter app, not # a Python package); without this, setuptools' automatic flat-layout diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py new file mode 100644 index 0000000..402c521 --- /dev/null +++ b/screen_locker/_mcp.py @@ -0,0 +1,141 @@ +"""Read-only MCP (Model Context Protocol) server for screen-locker. + +Exposes the locker's *read-only* status surface as typed MCP tools, so an MCP +client (Claude Code and its subagents) can query today's/this week's workout +compliance, the shutdown projection, and the lock-decision trace without +shelling out to the ``screen-locker-status`` CLI or opening the Tk window. + +Run via the dedicated venv that has the ``mcp`` extra installed:: + + ~/.venvs/screen-locker-mcp/bin/python -m screen_locker._mcp + +(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``). + +Safety invariants (do not break when adding tools): + * **READ-ONLY.** There are no write/action tools here, by design. In + particular there is deliberately **no** ``log_workout`` and no tool that + mutates workout/compliance state — the project rule is that workouts are + logged only from RunnerUp-verified TCX data, never from a caller's claim. + Every tool below calls only side-effect-free leaf helpers that *read* + on-disk state (``gather_status`` and the ``_compliance_state`` predicates), + never a mutating CLI/daemon entry point. + * **stdout is the JSON-RPC channel.** This module and every function a tool + calls must never write to stdout. All logging is routed to STDERR below, + and no tool calls ``screen_lock.py`` / ``status_view.main`` (which + ``print`` / open Tk windows / ``sys.exit``). + * **No secret ever leaves.** No tool returns the sync token + (``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates + load only status/state and return booleans, and the status snapshot + carries no secret material. +""" + +from __future__ import annotations + +from dataclasses import asdict +import logging +from pathlib import Path +import sys +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from screen_locker._compliance_state import ( + has_logged_today, + is_early_bird_pending, + is_scheduled_skip_today, + is_sick_day_today, +) +from screen_locker._constants import ( + EARLY_BIRD_PENDING_FILE, + SCHEDULED_SKIPS_FILE, +) +from screen_locker._sick_tracker import load_history +from screen_locker._status_data import format_summary_line, gather_status +from screen_locker.status_view import _compliance_state_word + +# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a +# single stray stdout write would corrupt the stream and kill the session. +logging.basicConfig( + level=logging.INFO, + stream=sys.stderr, + format="%(asctime)s [%(levelname)s] screen-locker-mcp: %(message)s", +) +logger = logging.getLogger(__name__) + +mcp = FastMCP("screen-locker") + +# The workout log lives beside the package, same default as +# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a +# private name across modules. +_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json" + + +# ────────────────────────────────────────────────────────────── +# Read tools (state-only; never expose secrets, never mutate) +# ────────────────────────────────────────────────────────────── + + +@mcp.tool() +def get_status() -> dict[str, Any]: + """Return the full read-only status snapshot. + + A JSON-friendly projection of ``gather_status()``: today's and this week's + workout outcomes, the shutdown-time projection, the lock-decision trace, + and the rolling sick-day / manual-workout budgets. Reads on-disk state + only (no ADB, sudo, or network) and never mutates anything. + """ + return asdict(gather_status()) + + +@mcp.tool() +def get_summary() -> dict[str, Any]: + """Return the one-line i3blocks summary plus the compliance-state word. + + ``summary_line`` is the same string the status bar shows + (``format_summary_line``); ``compliance_state`` is one of ``ok`` / ``warn`` + / ``lock`` (``_compliance_state_word``). + """ + snapshot = gather_status() + return { + "summary_line": format_summary_line(snapshot), + "compliance_state": _compliance_state_word(snapshot), + } + + +@mcp.tool() +def explain_lock() -> dict[str, Any]: + """Return why the screen is or is not being locked right now. + + A JSON-friendly projection of the ``LockExplanation`` reconstruction of the + lock-decision chain (which skip condition fired, the full ordered trace, + and any pending auto-upgrade opportunity). Read-only: the heat-skip stage + is reported as not-evaluated rather than triggering a live ``wttr.in`` call. + """ + return asdict(gather_status().lock_explanation) + + +@mcp.tool() +def get_flags() -> dict[str, bool]: + """Return the individual boolean lock-decision predicates for today. + + Each predicate reads its own on-disk state file and degrades to ``False`` + when that file is missing or unreadable — none of them mutate state or + expose secrets. + """ + history = load_history() + return { + "has_logged_today": has_logged_today(_LOG_FILE), + "is_scheduled_skip_today": is_scheduled_skip_today(SCHEDULED_SKIPS_FILE), + "is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE), + "is_sick_day_today": is_sick_day_today(history), + } + + +def main() -> None: + """Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs).""" + logger.info("Starting screen-locker MCP server (python=%s)", sys.executable) + mcp.run() # pragma: no cover + + +if __name__ == "__main__": + main() diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py new file mode 100644 index 0000000..46c8084 --- /dev/null +++ b/screen_locker/tests/test_mcp.py @@ -0,0 +1,147 @@ +"""Tests for the read-only MCP server tools in ``_mcp``. + +Helpers are patched at the ``_mcp`` module namespace (where they are imported), +keeping each tool's own logic under test while isolating the already-tested +leaf functions. ``get_status`` / ``explain_lock`` are exercised against a real +:class:`StatusSnapshot` so the ``dataclasses.asdict`` conversion (and its +JSON-friendliness) is covered for real, not mocked away. +""" + +from __future__ import annotations + +from dataclasses import asdict +import json +from unittest.mock import patch + +from screen_locker import _mcp +from screen_locker._compliance_state import ( + AutoUpgradeOpportunity, + LockExplanation, + PredicateResult, +) +from screen_locker._status_data import ( + DayStatus, + ManualWorkoutBudgetStatus, + ShutdownProjection, + ShutdownProjectionDay, + SickBudgetStatus, + StatusSnapshot, + WeeklySummary, +) + + +def _lock_explanation() -> LockExplanation: + return LockExplanation( + fired=True, + stage="full_lock_pending_heat_check", + reason="No skip condition applies.", + trace=(PredicateResult("scheduled_skip", fired=False, reason="not a skip"),), + auto_upgrade=AutoUpgradeOpportunity( + would_attempt=False, via="none", reason="No pending opportunity." + ), + heat_skip_evaluated=False, + ) + + +def _snapshot() -> StatusSnapshot: + day = DayStatus( + date="2026-07-10", + label="Fri Jul 10", + entry_type=None, + source="", + counted=False, + is_sick_day=False, + ) + return StatusSnapshot( + today=day, + week=WeeklySummary( + days=(day,), counted_count=2, minimum=4, remaining=2, extra=0 + ), + bonus_hours_this_week=0, + streak=1, + early_bird_extended=False, + shutdown=ShutdownProjection( + tonight=(22, 22, 5), + rest_of_week=( + ShutdownProjectionDay(label="Mon", hour=21, speculative=False), + ), + next_week_preview=( + ShutdownProjectionDay(label="Mon", hour=21, speculative=True), + ), + explanation="explanation text", + ), + lock_explanation=_lock_explanation(), + sick_budget=SickBudgetStatus( + used_7d=0, + budget_7d=1, + used_30d=0, + budget_30d=3, + used_90d=0, + budget_90d=10, + debt=0, + exhausted=False, + ), + manual_workout_budget=ManualWorkoutBudgetStatus( + used_7d=0, budget_7d=2, used_30d=0, budget_30d=5, exhausted=False + ), + generated_at="2026-07-10T12:00:00+00:00", + ) + + +class TestReadTools: + def test_get_status_returns_json_friendly_asdict(self) -> None: + snap = _snapshot() + with patch.object(_mcp, "gather_status", return_value=snap) as gs: + out = _mcp.get_status() + gs.assert_called_once_with() + assert out == asdict(snap) + # asdict output must be pure JSON (no datetime/dataclass leaks). + json.dumps(out) + + def test_get_summary_combines_line_and_state(self) -> None: + with ( + patch.object(_mcp, "gather_status") as gs, + patch.object(_mcp, "format_summary_line", return_value="LINE") as fsl, + patch.object(_mcp, "_compliance_state_word", return_value="ok") as csw, + ): + out = _mcp.get_summary() + assert out == {"summary_line": "LINE", "compliance_state": "ok"} + fsl.assert_called_once_with(gs.return_value) + csw.assert_called_once_with(gs.return_value) + + def test_explain_lock_returns_lock_explanation_asdict(self) -> None: + snap = _snapshot() + with patch.object(_mcp, "gather_status", return_value=snap): + out = _mcp.explain_lock() + assert out == asdict(snap.lock_explanation) + assert out["fired"] is True + json.dumps(out) + + +class TestGetFlags: + def test_flags_dict_and_predicate_wiring(self) -> None: + with ( + patch.object(_mcp, "load_history", return_value="HIST") as lh, + patch.object(_mcp, "has_logged_today", return_value=True) as hlt, + patch.object(_mcp, "is_scheduled_skip_today", return_value=False) as iss, + patch.object(_mcp, "is_early_bird_pending", return_value=True) as iep, + patch.object(_mcp, "is_sick_day_today", return_value=False) as isd, + ): + out = _mcp.get_flags() + assert out == { + "has_logged_today": True, + "is_scheduled_skip_today": False, + "is_early_bird_pending": True, + "is_sick_day_today": False, + } + lh.assert_called_once_with() + hlt.assert_called_once_with(_mcp._LOG_FILE) + iss.assert_called_once_with(_mcp.SCHEDULED_SKIPS_FILE) + iep.assert_called_once_with(_mcp.EARLY_BIRD_PENDING_FILE) + isd.assert_called_once_with("HIST") + + +def test_main_runs_stdio_server() -> None: + with patch.object(_mcp.mcp, "run") as run: + _mcp.main() + run.assert_called_once_with() diff --git a/scripts/setup_mcp.sh b/scripts/setup_mcp.sh new file mode 100755 index 0000000..6241517 --- /dev/null +++ b/scripts/setup_mcp.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# ============================================================================ +# Set up the dedicated virtualenv that hosts the screen-locker MCP server. +# Claude Code spawns this interpreter (see the repo-root .mcp.json) to run +# `python -m screen_locker._mcp` over stdio. +# +# The MCP SDK (`mcp`) lives ONLY in this venv — it is an optional dependency, +# deliberately kept out of the system/CLI/systemd python path. Both `mcp` and +# `screen_locker` must be importable by this one interpreter or the MCP server +# fails to start silently. +# +# The server is READ-ONLY: it exposes status/query tools only, never any tool +# that logs a workout or mutates state. +# +# Idempotent: safe to re-run to pick up dependency changes. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly SCRIPT_DIR REPO_DIR +readonly VENV_DIR="${HOME}/.venvs/screen-locker-mcp" + +main() { + echo "== Setting up MCP venv at ${VENV_DIR} ==" + + if [[ ! -d "${VENV_DIR}" ]]; then + python3 -m venv "${VENV_DIR}" + fi + + "${VENV_DIR}/bin/pip" install --quiet --upgrade pip + "${VENV_DIR}/bin/pip" install --quiet -e "${REPO_DIR}[mcp]" + + echo "== Verifying imports ==" + "${VENV_DIR}/bin/python" -c \ + "import mcp, screen_locker; print('mcp + screen_locker import OK')" + + echo + echo "Done. The server is registered via ${REPO_DIR}/.mcp.json" + echo "Restart Claude Code in this repo and approve the project MCP server prompt." +} + +main "$@"