mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Add read-only MCP server exposing workout/lock status
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:
parent
64d253bf91
commit
494cc74bb5
10
.mcp.json
Normal file
10
.mcp.json
Normal 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
32
README.md
Normal 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`.
|
||||||
@ -11,6 +11,14 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
screen-locker-status = "screen_locker.status_view:main"
|
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]
|
[tool.setuptools.packages.find]
|
||||||
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
||||||
# a Python package); without this, setuptools' automatic flat-layout
|
# a Python package); without this, setuptools' automatic flat-layout
|
||||||
|
|||||||
141
screen_locker/_mcp.py
Normal file
141
screen_locker/_mcp.py
Normal 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()
|
||||||
147
screen_locker/tests/test_mcp.py
Normal file
147
screen_locker/tests/test_mcp.py
Normal 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
45
scripts/setup_mcp.sh
Executable 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 "$@"
|
||||||
Loading…
Reference in New Issue
Block a user