steam-backlog-enforcer/steam_backlog_enforcer/_mcp.py
Krzysztof kuhy Rudnicki 26c4771192
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
Add MCP server exposing the enforcer to Claude Code
Expose the backlog enforcer's read surface and two gated write actions as
typed MCP tools (steam_backlog_enforcer._mcp) so Claude Code and its
subagents can query and drive the backlog without shelling out.

- _mcp.py: FastMCP stdio server. Read tools get_dataset/get_status/
  get_stats/list_backlog; gated writes pick_manual/block_gaming (preview
  unless confirm=True). Logs to stderr; never exposes Config/the API key.
- _actions.py: stdout-free, state-only cores shared by CLI and MCP
  (apply_manual_pick, status_payload, is_manual_pick_locked). cmd_pick_manual
  now delegates its state mutation here.
- .mcp.json + scripts/setup_mcp.sh: project-scoped registration via a
  dedicated ~/.venvs/steam-backlog-mcp interpreter; mcp is an optional-dep,
  kept out of the systemd/CLI system-python path.
- pyproject: fix setuptools flat-layout discovery and declare the previously
  undeclared direct deps aiohttp + websockets, un-breaking the editable install.
- 100% coverage of the new modules; server 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
2026-07-10 21:05:17 +02:00

262 lines
9.8 KiB
Python

"""MCP (Model Context Protocol) server for Steam Backlog Enforcer.
Exposes the enforcer's read surface and two *gated* write actions as typed MCP
tools, so an MCP client (Claude Code and its subagents) can query and — with
explicit confirmation — drive the backlog without shelling out to the CLI.
Run via the dedicated venv that has the ``mcp`` extra installed::
~/.venvs/steam-backlog-mcp/bin/python -m steam_backlog_enforcer._mcp
(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
Safety invariants (do not break when adding tools):
* **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
tools call only stdout-free leaf helpers (never the ``cmd_*`` handlers, which
``_echo`` to stdout / ``input()`` / ``sys.exit()``).
* **No secret ever leaves.** There is no tool that returns ``Config`` or reads
``config.json``; read tools load only ``State`` and the secrets-free web
dataset. Game-name lookups use the on-disk snapshot only (no Steam API key).
* **Writes are gated.** Every write tool defaults to a dry-run preview and
mutates only when ``confirm=True``. These write tools must never be added to
a permission allowlist (a subagent could then bypass the human).
"""
from __future__ import annotations
import logging
import sys
from typing import Any
from mcp.server.fastmcp import FastMCP
from steam_backlog_enforcer._actions import (
MANUAL_LOCK_DAYS,
apply_manual_pick,
status_payload,
)
from steam_backlog_enforcer._total_block import start_total_block
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
from steam_backlog_enforcer.config import State, load_snapshot
from steam_backlog_enforcer.steam_api import GameInfo
# 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] steam-mcp: %(message)s",
)
logger = logging.getLogger(__name__)
mcp = FastMCP("steam-backlog-enforcer")
_DEFAULT_LIST_LIMIT = 50
def _resolve_game_name(app_id: int) -> str | None:
"""Resolve a game name from the on-disk snapshot only (no Config / API).
Deliberately snapshot-only: it never constructs ``Config`` and never touches
the Steam API key, so it is safe to call from an MCP tool. A game absent from
the last snapshot simply resolves to ``None``.
Args:
app_id: The Steam app id to look up.
Returns:
The game name, or ``None`` if not present in the snapshot.
"""
snapshot = load_snapshot()
if snapshot:
for entry in snapshot:
if entry.get("app_id") == app_id:
return str(entry["name"])
return None
def _backlog_sort_key(game: GameInfo) -> tuple[int, float]:
"""Sort incomplete games shortest-completionist-first, unknowns last."""
if game.completionist_hours > 0:
return (0, game.completionist_hours)
return (1, 0.0)
# ──────────────────────────────────────────────────────────────
# Read tools (State-only; never expose Config / the Steam API key)
# ──────────────────────────────────────────────────────────────
@mcp.tool()
def get_dataset() -> dict[str, Any]:
"""Return the full, secrets-free backlog dataset.
Includes every incomplete candidate game with HowLongToBeat times,
ProtonDB tiers, pace-vs-HLTB calibration, and the default CLI thresholds —
the same projection the local web UI consumes. Reads on-disk caches only
(no network, no Steam API key).
"""
return dataset_to_payload(build_web_dataset(State.load()))
@mcp.tool()
def get_status() -> dict[str, Any]:
"""Return the current enforcer status.
Reports the assigned game, finished count, whether the Steam store is
blocked, installed-game count, any active total block (with days remaining),
and whether a manual-pick lock is in force.
"""
return status_payload(State.load())
@mcp.tool()
def get_stats() -> dict[str, Any]:
"""Return backlog completion-time estimates.
A focused subset of the dataset: the default qualifying-games summary
(rush / leisure / worst-case totals) and the player's measured pace versus
HowLongToBeat.
"""
payload = dataset_to_payload(build_web_dataset(State.load()))
return {
"default_summary": payload["default_summary"],
"pace_vs_hltb": payload["pace_vs_hltb"],
}
@mcp.tool()
def list_backlog(limit: int = _DEFAULT_LIST_LIMIT) -> dict[str, Any]:
"""List incomplete games, shortest-completionist-first, capped at ``limit``.
Args:
limit: Maximum number of games to return (non-positive returns none).
"""
snapshot = load_snapshot()
if snapshot is None:
return {
"total": 0,
"returned": 0,
"games": [],
"note": "No snapshot found. Run 'scan' first.",
}
games = [GameInfo.from_snapshot(entry) for entry in snapshot]
incomplete = sorted((g for g in games if not g.is_complete), key=_backlog_sort_key)
capped = incomplete[: max(0, limit)]
return {
"total": len(incomplete),
"returned": len(capped),
"games": [
{
"app_id": g.app_id,
"name": g.name,
"completion_pct": round(g.completion_pct, 1),
"completionist_hours": g.completionist_hours,
}
for g in capped
],
}
# ──────────────────────────────────────────────────────────────
# Gated write tools (preview unless confirm=True; NEVER allowlist these)
# ──────────────────────────────────────────────────────────────
@mcp.tool()
def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Lock the enforcer to a game by Steam app_id (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview of what confirming would do. Call again with
``confirm=True`` to apply. Applying overrides the current assignment and
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until 100%
achievements). Unlike the CLI's ``pick-manual``, this mutates **state only**
— it never uninstalls, installs, or hides games.
Args:
app_id: The Steam app id to lock in (must exist in the last snapshot).
confirm: Set ``True`` to actually apply the pick; otherwise preview.
"""
game_name = _resolve_game_name(app_id)
if game_name is None:
return {
"ok": False,
"reason": f"AppID={app_id} not found in the snapshot. Run 'scan' first.",
}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
"effect": (
"Overrides the current assignment and locks all other commands "
f"for {MANUAL_LOCK_DAYS} days or until 100% achievements."
),
"confirm_required": True,
}
apply_manual_pick(State.load(), app_id, game_name)
logger.info("pick_manual applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def block_gaming(days: int, *, confirm: bool = False) -> dict[str, Any]:
"""Start a total gaming block for ``days`` days (gated, privileged write).
With ``confirm=False`` (the default) returns a preview only. With
``confirm=True`` it attempts the block, which **requires root** (it edits
``/etc/hosts`` and uninstalls Steam). The MCP server runs unprivileged, so
this will normally return ``{"ok": false, "reason": "requires elevated
privileges"}`` rather than succeeding. There is no in-app undo.
Args:
days: Whole number of days to block all gaming (must be >= 1).
confirm: Set ``True`` to attempt the block; otherwise preview.
"""
if days < 1:
return {"ok": False, "reason": "days must be at least 1."}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "block_gaming",
"days": days,
"effect": (
f"For {days} day(s): uninstalls Steam, kills game launchers, and "
"blocks Steam + game-website domains. There is NO in-app undo."
),
"requires_root": True,
"confirm_required": True,
}
try:
applied = start_total_block(days)
except (OSError, RuntimeError) as exc: # never crash the server on a write
logger.warning("block_gaming failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
if not applied:
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("block_gaming applied: %d day(s)", days)
return {"ok": True, "applied": True, "action": "block_gaming", "days": days}
def main() -> None:
"""Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs)."""
logger.info(
"Starting steam-backlog-enforcer MCP server (python=%s)", sys.executable
)
mcp.run() # pragma: no cover
if __name__ == "__main__":
main()