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