feat(mcp): serveur MCP "mac" pour piloter le Mac (builds Winnie)

MCP stdio lancé par hermes-agent sur tower ; parle au Mac en ssh (alias
maclan puis mac, clé hermes). Tools : status (winnie-doctor + révisions),
sync, doctor_fix, xcode_fix (3 opérations root via la règle sudo
NOPASSWD xcode-select/xcodebuild/xcrun), runtime_install et les builds
(arrière-plan, suivi par log_tail) : ios_init, ios_build_sim,
macos_build.

Vérifié : pycodestyle/pyflakes, build de la dérivation, sonde stdio
(tools/list + appels réels sur le Mac), éval du toplevel avant commit.
This commit is contained in:
Hermes Agent
2026-10-24 15:54:34 -04:00
parent eb71f8ae64
commit 59b41c48f0
3 changed files with 221 additions and 0 deletions
+1
View File
@@ -70,6 +70,7 @@
./anki-sync-server
./anki-mcp
./winnie
./mac-mcp
./wise-mcp
./bank-mcp
./llm-steamdeck
+16
View File
@@ -0,0 +1,16 @@
# services/mac-mcp/default.nix
# Serveur MCP "mac" : pilote le Mac de marc depuis l'hote de l'agent, en ssh
# (build Winnie macOS + iOS, etat du poste, sync du depot, operations root
# Xcode via la regle sudo NOPASSWD). Le script vit dans ./mac-mcp.py ; hote
# ssh choisi au premier appel : alias maclan (LAN) puis mac (tunnel inverse
# par rack), cle id_ed25519 de hermes. Contexte : skill marc-mac-operations.
{pkgs, ...}: let
mac-mcp = pkgs.writers.writePython3Bin "mac-mcp" {
libraries = [pkgs.python3Packages.mcp];
} (builtins.readFile ./mac-mcp.py);
in {
services.hermes-agent.mcpServers.mac = {
command = "${mac-mcp}/bin/mac-mcp";
timeout = 1800;
};
}
+204
View File
@@ -0,0 +1,204 @@
"""Serveur MCP "mac" : pilote le Mac de marc depuis tower via ssh.
Sert le cycle de build de Winnie : etat du poste (winnie-doctor), sync du
depot depuis tower, reparations (--fix), operations root Xcode (regle sudo
NOPASSWD xcode-select/xcodebuild/xcrun, voir sudoers.d/20-hermes-xcode),
runtime du simulateur iOS (~8 Go, en arriere-plan) et builds tauri
(macOS desktop ; iOS simulateur) lances en arriere-plan, journaux
consultables par log_tail.
Tout tourne en ssh sous le compte marc ; les longs builds rendent la main
tout de suite et se suivent par log_tail. Hote choisi au premier appel :
alias maclan (LAN) puis mac (tunnel inverse par rack).
"""
import os
import shlex
import subprocess
try:
from mcp.server import MCPServer # mcp >= 2
except ImportError:
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
HOSTS = os.environ.get("WINNIE_MAC_HOSTS", "maclan mac").split()
REPO = os.environ.get("WINNIE_MAC_REPO", "/Users/marc/git/winnie")
BIN = "/etc/profiles/per-user/marc/bin"
PATH_PREFIX = 'export PATH="/run/current-system/sw/bin:%s:$PATH";' % BIN
NIX = "nix --extra-experimental-features 'nix-command flakes'"
# Motifs pgrep : le [x] empeche le process du pgrep (dont la ligne de
# commande contient le motif) de se compter lui-meme.
LOGS = {
"runtime": "[d]ownloadPlatform",
"ios-build": "[t]auri ios build",
"macos-build": "[t]auri build",
}
mcp = MCPServer("mac")
_host = None
def _pick_host():
global _host
if _host is not None:
return _host
errors = []
for h in HOSTS:
try:
r = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=6",
"-o", "StrictHostKeyChecking=accept-new", h, "true"],
capture_output=True, text=True, timeout=20,
)
except subprocess.TimeoutExpired:
errors.append("%s: timeout" % h)
continue
if r.returncode == 0:
_host = h
return h
lines = (r.stderr or "").strip().splitlines()
errors.append("%s: %s" % (h, lines[-1] if lines else "rc!=0"))
raise RuntimeError("Mac injoignable (%s)" % " ; ".join(errors))
def _ssh(cmd, timeout=900):
host = _pick_host()
r = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host,
"bash -c " + shlex.quote(PATH_PREFIX + " " + cmd)],
capture_output=True, text=True, timeout=timeout,
)
out = ((r.stdout or "") + (r.stderr or "")).strip()
if r.returncode != 0:
raise RuntimeError(
"ssh %s a echoue (rc=%s) :\n%s" % (host, r.returncode, out[-2000:])
)
return out
def _start(name, cmd):
log = "/Users/marc/winnie-%s.log" % name
line = "cd %s && nohup %s >%s 2>&1 </dev/null & echo pid=$!" % (
REPO, cmd, log)
out = _ssh(line, timeout=60)
return "%s lance en arriere-plan (%s) ; suivi log_tail('%s').\n%s" % (
name, log, name, out)
def _dev(cmd):
return "%s develop -c bash -c %s" % (NIX, shlex.quote(cmd))
@mcp.tool()
def status() -> str:
"""Etat du poste Mac pour builder Winnie.
winnie-doctor (Xcode, licence, SDK iOS, runtime du simulateur,
node_modules, API) + revisions des depots ~/git/winnie et ~/git/nixos
+ hote ssh joignable (maclan ou mac).
"""
revs = _ssh(
"cd %s && git log -1 --format='winnie %%h %%s' ; "
"cd ~/git/nixos && git log -1 --format='nixos %%h %%s'" % REPO,
timeout=60,
)
doc = _ssh("%s/winnie-doctor ; echo RC=$?" % BIN, timeout=180)
return "hote ssh : %s\n%s\n\n%s" % (_pick_host(), revs, doc)
@mcp.tool()
def sync(delete: bool = False) -> str:
"""Rafraichit ~/git/winnie sur le Mac depuis tower (rsync ssh).
delete=False (defaut) garde les fichiers locaux en trop ;
delete=True fait un miroir strict de tower.
"""
arg = " --delete" if delete else ""
return _ssh("%s/winnie-sync%s" % (BIN, arg), timeout=600)
@mcp.tool()
def doctor_fix() -> str:
"""winnie-doctor --fix : repare le poste Mac (depot, node_modules,
Xcode, licence, runtime). Les operations root passent par la regle
sudo NOPASSWD deployee. Relancable sans risque.
"""
return _ssh("%s/winnie-doctor --fix ; echo RC=$?" % BIN, timeout=1800)
@mcp.tool()
def xcode_fix() -> str:
"""Pointe xcode-select sur /Applications/Xcode.app, accepte la licence
Xcode et installe les premiers composants (les trois operations root,
via sudo). A relancer apres toute (re)installation ou mise a jour
majeure de Xcode ; conclut sur l'etat du SDK.
"""
return _ssh(
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app"
" && sudo -n /usr/bin/xcodebuild -license accept"
" && sudo -n /usr/bin/xcodebuild -runFirstLaunch"
" ; /usr/bin/xcode-select -p"
" ; /usr/bin/xcrun --sdk iphoneos --show-sdk-version",
timeout=1800,
)
@mcp.tool()
def runtime_install() -> str:
"""Telecharge le runtime du simulateur iOS sur le Mac (~8 Go, long) en
arriere-plan ; suivre avec log_tail('runtime'). Inutile si status le
montre deja present.
"""
return _start("runtime",
"sudo -n /usr/bin/xcodebuild -downloadPlatform iOS")
@mcp.tool()
def ios_init() -> str:
"""Sur le Mac : npm ci si besoin puis tauri ios init (projet Xcode +
pod install) dans le devShell winnie. Bloque quelques minutes ; sans
effet si app/src-tauri/gen/apple existe deja.
"""
inner = ("cd app && { [ -d node_modules ] || npm ci; }"
" && { [ -d src-tauri/gen/apple ]"
" || ./node_modules/.bin/tauri ios init; }")
return _ssh("cd %s && %s" % (REPO, _dev(inner)), timeout=1500)
@mcp.tool()
def ios_build_sim() -> str:
"""Build iOS pour le simulateur (aucun compte Apple requis), en
arriere-plan ; suivre avec log_tail('ios-build'). Pre-requis : runtime
du simulateur (runtime_install) et gen/apple (ios_init).
"""
inner = ("cd app && ./node_modules/.bin/tauri ios build"
" --target aarch64-sim; echo BUILD_EXIT=$?")
return _start("ios-build", _dev(inner))
@mcp.tool()
def macos_build() -> str:
"""Build macOS desktop (.app + .dmg, Command Line Tools suffisent), en
arriere-plan ; suivre avec log_tail('macos-build').
"""
inner = ("cd app && ./node_modules/.bin/tauri build"
"; echo BUILD_EXIT=$?")
return _start("macos-build", _dev(inner))
@mcp.tool()
def log_tail(name: str, lines: int = 40) -> str:
"""Dernieres lignes du journal d'une tache en arriere-plan ; name parmi
runtime, ios-build, macos-build. Dit aussi si la tache tourne encore.
"""
if name not in LOGS:
raise RuntimeError("nom inconnu : %s (attendu : %s)"
% (name, ", ".join(sorted(LOGS))))
cmd = ("tail -n %d /Users/marc/winnie-%s.log 2>/dev/null"
" ; echo --- ; pgrep -fl %s | head -5 || true")
return _ssh(cmd % (lines, name, shlex.quote(LOGS[name])), timeout=60)
if __name__ == "__main__":
mcp.run()