Add read-only MCP server exposing workout/lock status
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

Expose the screen-locker's compliance and lock-decision state as read-only
MCP tools (screen_locker._mcp) so Claude Code and its subagents can query
workout status without shelling out to the CLI or opening the Tk window.

- _mcp.py: FastMCP stdio server. Read-only tools get_status, get_summary,
  explain_lock, get_flags. Import leaf over stdout-free status functions
  (gather_status, format_summary_line, explain_lock_decision, predicates);
  logs to stderr. NO write/action tools by design — 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.
- .mcp.json + scripts/setup_mcp.sh: registration via a dedicated
  ~/.venvs/screen-locker-mcp interpreter; mcp is an optional-dep.
- 100% coverage of the new module; verified end-to-end over stdio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v
This commit is contained in:
Krzysztof Rudnicki 2026-07-10 21:28:09 +02:00
parent 64d253bf91
commit 494cc74bb5
6 changed files with 383 additions and 0 deletions

10
.mcp.json Normal file
View File

@ -0,0 +1,10 @@
{
"mcpServers": {
"screen-locker": {
"type": "stdio",
"command": "${HOME}/.venvs/screen-locker-mcp/bin/python",
"args": ["-m", "screen_locker._mcp"],
"env": {}
}
}
}

32
README.md Normal file
View File

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

View File

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

141
screen_locker/_mcp.py Normal file
View File

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

View File

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

45
scripts/setup_mcp.sh Executable file
View File

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