feat(mcp): Anki headless + acces LAN filtre aux MCP

anki-mcp : Anki sans bureau et addon AnkiMCP, HTTP MCP sur 3141,
connecte a la collection du serveur de sync.
mcp-lan-access republie sur l'adresse LAN de tower les endpoints
loopback (3141, 8710, 6060, 23373), firewall limite au Mac ; l'addon
Anki autorise ce Host (anti DNS-rebinding).
arr-mcp ne publie plus 6060 sur 0.0.0.0. Le module mcp-lan-access
est importe par services/default.nix avec anki-mcp, wise-mcp et
bank-mcp.
This commit is contained in:
Hermes Agent
2026-10-19 02:07:19 -04:00
parent c427110b4b
commit dbf57bcb74
9 changed files with 461 additions and 5 deletions
+31
View File
@@ -238,6 +238,37 @@ in {
openFirewall = true;
};
# ========================================================================
# ANKI - serveur MCP headless (Anki + addon AnkiMCP)
# ========================================================================
# Anki sans bureau + addon AnkiMCP, expose en HTTP MCP sur la loopback
# (127.0.0.1:3141) et consomme par Hermes (mcpServers.anki). La collection
# est celle du compte "marc" du serveur de sync anki.marcpartensky.com :
# ce service en est un client de plus, comme le Mac et le telephone.
# Rien n'est ouvert au firewall ni expose par newt (exposition publique
# possible plus tard, avec cle http_api_key, cf services/anki-mcp).
services.anki-mcp.enable = true;
# ========================================================================
# MCP - accès LAN filtré (URLs cliquables depuis Hermes Desktop sur le Mac)
# ========================================================================
# Republie sur l'adresse LAN de tower les quatre endpoints MCP HTTP qui
# écoutent en loopback. Le firewall n'accepte que 192.168.1.48 (le Mac) ;
# rien n'est ouvert à la box ni à Pangolin. Les modules nextcloud-mcp,
# arr-mcp, anki-mcp et le serveur beeper lisent `host` pour construire leur
# URL MCP, donc une seule valeur à changer si l'adresse DHCP bouge.
services.mcp-lan-access = {
enable = true;
host = "192.168.1.44";
clientAddress = "192.168.1.48";
endpoints = {
nextcloud = 8710;
anki = 3141;
arrmcp = 6060;
beeper = 23373;
};
};
# ========================================================================
# NGINX - vhosts locaux (TLS porte par Pangolin/newt, pas par nginx ici)
# ========================================================================
+74
View File
@@ -0,0 +1,74 @@
"""Amorce le compte du serveur de sync pour l'instance headless AnkiMCP.
Anki n'expose aucun chemin non interactif pour renseigner « serveur de sync
personnalisé + compte + mot de passe » : la saisie passe par l'interface
graphique. Ce module le fait une fois au chargement du profil, puis persiste
la clé de sync (hkey) dans le profil, exactement comme le ferait
`aqt.sync` après un login réussi : c'est ce que lit ensuite `mw.pm.sync_auth()`,
donc l'outil `sync` de l'addon AnkiMCP.
Variables d'environnement attendues (posées par le service systemd) :
ANKI_SYNC_USER compte sur le serveur de sync
ANKI_SYNC_ENDPOINT URL du serveur, ex. https://anki.marcpartensky.com
ANKI_SYNC_PASSWORD_FILE fichier contenant le mot de passe (LoadCredential)
Idempotent : si le profil a déjà une clé de sync, rien n'est fait. La sync
automatique au démarrage est désactivée : la première mise en accord avec le
serveur (collection locale vide) demande une sync unidirectionnelle, et
l'assistant Anki l'affiche en boîte de dialogue modale, intenable sans écran.
C'est l'outil `sync` du serveur MCP qui pilote la sync, avec un choix de
direction explicite.
"""
import os
from aqt import gui_hooks, mw
_TAG = "[ankimcp-sync-bootstrap]"
# Évite d'empiler les tentatives si le profil est rouvert dans le même run.
_done = False
def _log(message: str) -> None:
print(f"{_TAG} {message}", flush=True)
def _bootstrap() -> None:
global _done
if _done or mw is None or mw.col is None or mw.pm is None:
return
_done = True
try:
# Sans écran, une sync automatique qui tombe sur un conflit ouvre un
# dialogue modal : on la désactive, l'agent déclenche la sync lui-même.
if mw.pm.profile.get("autoSync"):
mw.pm.profile["autoSync"] = False
if mw.pm.profile.get("syncKey"):
_log("profil déjà rattaché au serveur de sync, rien à faire")
return
user = os.environ.get("ANKI_SYNC_USER", "")
endpoint = os.environ.get("ANKI_SYNC_ENDPOINT", "")
password_file = os.environ.get("ANKI_SYNC_PASSWORD_FILE", "")
if not (user and endpoint and password_file):
_log("ANKI_SYNC_USER/ANKI_SYNC_ENDPOINT/ANKI_SYNC_PASSWORD_FILE "
"incomplets, amorçage ignoré")
return
with open(password_file, encoding="utf-8") as handle:
password = handle.read().strip()
auth = mw.col.sync_login(user, password, endpoint)
mw.pm.set_sync_key(auth.hkey)
mw.pm.set_sync_username(user)
mw.pm.set_custom_sync_url(endpoint)
_log(f"compte {user} rattaché à {endpoint}")
except Exception as exc: # réseau, identifiants, schéma : on réessaiera
_done = False
_log(f"amorçage échoué ({type(exc).__name__}: {exc})")
gui_hooks.profile_did_open.append(_bootstrap)
@@ -0,0 +1,6 @@
{
"package": "ankimcp_sync_bootstrap",
"name": "AnkiMCP sync bootstrap",
"mod": 0,
"conflicts": []
}
+165
View File
@@ -0,0 +1,165 @@
# services/anki-mcp/default.nix
# Serveur MCP Anki headless : Anki (sans bureau, sous Xvfb) + addon AnkiMCP
# (ankimcp.ai), qui expose la collection en HTTP MCP sur la loopback.
#
# Choix d'architecture : l'addon AnkiMCP tourne DANS Anki (aucun processus
# séparé, aucun AnkiConnect à installer), donc la seule façon d'en faire un
# service est de faire tourner un Anki sans écran. La collection de ce service
# et celle de ton Anki de bureau (Mac, téléphone) se réconcilient par le
# serveur de sync auto-hébergé (services/anki-sync-server), dont ce module
# emprunte le secret : un seul compte, une seule collection, plusieurs clients.
#
# Détails qui coûtent du temps si on les ignore :
# - Anki lit sa liste d'addons dans $ANKI_ADDONS quand cette variable est
# posée (patch nixpkgs), sinon dans <base>/addons21. On construit donc un
# dossier d'addons dédié : l'addon AnkiMCP plus un petit addon d'amorçage,
# car Anki n'offre AUCUN chemin non interactif pour renseigner le serveur de
# sync et le compte (la saisie passe par la boîte de dialogue des
# préférences). L'addon d'amorçage fait le login au chargement du profil et
# persiste la clé, ce que lit ensuite mw.pm.sync_auth() donc l'outil `sync`.
# - L'addon "nixos" livré par pkgs.anki.withAddons n'est PAS repris ici : il
# remplace addonManager.writeConfig par une boîte de dialogue modale,
# intenable sans écran.
# - Anki vient du nixpkgs de l'input anki-mcp, celui qui a construit l'addon :
# l'addon y est lié contre son interpréteur python (3.14) via vendor/shared,
# un autre nixpkgs casse l'ABI des modules compilés (pydantic_core).
# - Pas de -p <profil> au premier démarrage : le profil n'existe pas encore et
# Anki afficherait alors le sélecteur de profils (modal). Sans -p, Anki crée
# le profil unique tout seul puis le charge silencieusement.
{ config, lib, pkgs, inputs, ... }:
let
cfg = config.services.anki-mcp;
system = pkgs.stdenv.hostPlatform.system;
# nixpkgs qui a construit l'addon ET le paquet Anki (cf en-tête).
ankiPkgs = inputs.anki-mcp.inputs.nixpkgs.legacyPackages.${system};
mcpAddon = inputs.anki-mcp.packages.${system}.addon;
# Addon d'amorçage : rattache le profil au serveur de sync (login une fois).
bootstrapAddon = pkgs.runCommand "anki-addon-ankimcp-sync-bootstrap" { } ''
mkdir -p $out/share/anki/addons/ankimcp_sync_bootstrap
cp ${./bootstrap/__init__.py} $out/share/anki/addons/ankimcp_sync_bootstrap/__init__.py
cp ${./bootstrap/manifest.json} $out/share/anki/addons/ankimcp_sync_bootstrap/manifest.json
'';
# AnkiMCP protège son transport HTTP contre le DNS-rebinding : le Host de
# l'URL LAN (192.168.1.44:3141) n'est accepté que s'il figure dans
# http_allowed_hosts, sinon le serveur répond 421 Misdirected Request. Le
# dossier d'addon de l'input est en lecture seule (store), on en fait donc
# une copie dont seul config.json change. La copie n'est utilisée que quand
# l'accès LAN est activé.
mcpAddonForLan = pkgs.runCommand "anki-addon-anki-mcp-server-lan" { } ''
mkdir -p $out
cp -r ${mcpAddon}/share/anki/addons/anki-mcp-server/. $out/
chmod -R u+w $out
${pkgs.jq}/bin/jq --arg host "${config.services.mcp-lan-access.host}:${toString port}" \
'.http_allowed_hosts = [$host]' $out/config.json > $out/config.json.new
mv $out/config.json.new $out/config.json
'';
# Dossier d'addons d'Anki ($ANKI_ADDONS) : un simple répertoire de liens.
addonsDir = pkgs.runCommand "ankimcp-addons" { } ''
mkdir -p $out
ln -s ${
if config.services.mcp-lan-access.enable
then mcpAddonForLan
else "${mcpAddon}/share/anki/addons/anki-mcp-server"
} $out/anki-mcp-server
ln -s ${bootstrapAddon}/share/anki/addons/ankimcp_sync_bootstrap $out/ankimcp_sync_bootstrap
'';
stateDir = "/var/lib/anki-mcp";
port = 3141;
# Tant que prefs21.db n'existe pas, Anki se croit au premier lancement et
# ouvre un sélecteur de langue modal qui bloque l'ouverture du profil : on
# amorce le fichier une fois (cf l'en-tête de seed-prefs.py).
seedPrefs = pkgs.writeShellScript "anki-mcp-seed-prefs" ''
exec ${pkgs.python3}/bin/python3 ${./seed-prefs.py} ${stateDir}/anki/prefs21.db
'';
start = pkgs.writeShellScript "anki-mcp-start" ''
set -eu
# Tout l'état d'Anki vit sous $HOME (profil, collection, medias, logs).
export HOME=${stateDir}
export ANKI_SYNC_USER=${cfg.syncUser}
export ANKI_SYNC_ENDPOINT=${cfg.syncEndpoint}
export ANKI_SYNC_PASSWORD_FILE="$CREDENTIALS_DIRECTORY/anki-sync-password"
# Anki écrit dans son profil : le chemin -b reste sous $HOME pour que
# DynamicUser et StateDirectory suffisent (aucun /home/user en jeu).
exec ${pkgs.xvfb-run}/bin/xvfb-run -a -s "-screen 0 1280x1024x24" \
${ankiPkgs.anki}/bin/anki -b ${stateDir}/anki
'';
in {
options.services.anki-mcp = {
enable = lib.mkEnableOption "serveur MCP Anki headless (Anki + addon AnkiMCP)";
syncUser = lib.mkOption {
type = lib.types.str;
default = "marc";
description = "Compte du serveur de sync Anki utilisé par l'instance headless.";
};
syncEndpoint = lib.mkOption {
type = lib.types.str;
default = "https://anki.marcpartensky.com";
description = ''
URL du serveur de sync (services/anki-sync-server). Le mot de passe
vient du secret anki-sync-server/password, lu par LoadCredential.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.anki-mcp = {
description = "Anki + AnkiMCP headless (serveur MCP Anki pour l'agent)";
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
# La sync est pilotée par l'outil `sync` du MCP, pas au démarrage :
# cf l'addon d'amorçage (autoSync désactivé).
serviceConfig = {
DynamicUser = true;
StateDirectory = "anki-mcp";
RuntimeDirectory = "anki-mcp";
WorkingDirectory = stateDir;
# Même compte que le serveur de sync : le secret est déjà là.
LoadCredential = "anki-sync-password:${config.sops.secrets."anki-sync-server/password".path}";
ExecStartPre = seedPrefs;
ExecStart = start;
Restart = "always";
RestartSec = "5s";
TimeoutStopSec = "30s";
};
environment = {
ANKI_ADDONS = "${addonsDir}";
# Qt/WebEngine sans écran : rendu logiciel, pas de bac à sable.
# DISPLAY est posé par xvfb-run (display libre choisi à l'exécution).
QTWEBENGINE_DISABLE_SANDBOX = "1";
QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu";
LIBGL_ALWAYS_SOFTWARE = "1";
QT_QPA_PLATFORM = "xcb";
QT_XKB_CONFIG_ROOT = "${pkgs.xkeyboard_config}/share/X11/xkb";
LANG = "C.UTF-8";
};
};
# Câblage Hermes : le MCP est consommé par URL (transport HTTP), comme
# nextcloud ou arrmcp. Le port reste sur la loopback et rien n'est ouvert au
# firewall. L'URL LAN du module services/mcp-lan-access ne fait que rendre
# le lien cliquable depuis Hermes Desktop sur le Mac ; le firewall la
# restreint au Mac et tower la joint par lo.
# Exposition publique éventuelle : passer par une ressource Pangolin/newt
# dédiée (services/newt) avec http_api_key + http_allowed_hosts posés dans
# la config de l'addon, jamais sans clé.
services.hermes-agent.mcpServers.anki = {
url =
if config.services.mcp-lan-access.enable
then "http://${config.services.mcp-lan-access.host}:${toString port}/"
else "http://127.0.0.1:${toString port}/";
timeout = 120;
};
};
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Crée le prefs21.db d'Anki s'il n'existe pas encore.
Anki considère `prefs21.db` absent comme « premier lancement » et affiche
alors un sélecteur de langue MODAL (aqt/__init__.py, `if firstTime:
pm.setDefaultLang()`), puis attend un clic. Sans écran, ce dialogue bloque
l'ouverture du profil : pas de collection, pas de serveur MCP. Les images
headless d'ankimcp contournent le problème en embarquant un prefs21.db
pré-configuré ; ici on le génère, ce qui reste lisible.
Contenu minimal attendu par Anki (aqt/profiles.py) : une base sqlite avec une
table `profiles`, et une ligne `_global` dont la colonne `data` est un dict
picklé. Les clés sont celles de `metaConf`, avec `firstRun` à False et
`defaultLang` renseigné (c'est ce dernier qui évite le sélecteur).
Le script est idempotent : il ne touche jamais un prefs21.db existant, donc
aucun risque d'écraser un réglage posé par Anki ensuite.
"""
import os
import pickle
import random
import sqlite3
import sys
import time
LANG = os.environ.get("ANKI_LANG", "en_US")
def main() -> int:
path = sys.argv[1]
os.makedirs(os.path.dirname(path), exist_ok=True)
if os.path.exists(path):
return 0
meta = {
"ver": 0,
"updates": True,
"created": int(time.time()),
"id": random.randrange(0, 2**63),
"lastMsg": 0,
"suppressUpdate": False,
"firstRun": False,
"defaultLang": LANG,
}
con = sqlite3.connect(path)
con.execute(
"create table if not exists profiles "
"(name text primary key collate nocase, data blob not null)"
)
con.execute(
"insert or replace into profiles values ('_global', ?)",
(pickle.dumps(meta, protocol=4),),
)
con.commit()
con.close()
print(f"prefs21.db amorcé ({path}, langue {LANG})")
return 0
if __name__ == "__main__":
sys.exit(main())
+12 -3
View File
@@ -6,7 +6,11 @@
in {
virtualisation.oci-containers.containers.arr-mcp = {
image = "ghcr.io/bardesss/arr-mcp:latest";
ports = [ "${toString port}:6060" ];
# Port publié sur la loopback uniquement : l'accès LAN passe par le
# redirecteur filtré de services/mcp-lan-access, jamais par le container.
# (Avant, "6060:6060" publiait sur 0.0.0.0 et seul le firewall retenait
# l'instance non claimée.)
ports = [ "127.0.0.1:${toString port}:6060" ];
# Le serveur arr-mcp doit atteindre Radarr (8086), Sonarr (8006),
# Jellyfin (8096) et Prowlarr (8087) en loopback.
environment = {
@@ -17,9 +21,14 @@ in {
};
};
# Hermes : connexion au serveur MCP arr-mcp (transport HTTP, loopback)
# Hermes : connexion au serveur MCP arr-mcp (transport HTTP). L'URL passe par
# le redirecteur LAN de services/mcp-lan-access pour être cliquable depuis
# Hermes Desktop sur le Mac ; côté tower, le trafic local repasse par lo.
services.hermes-agent.mcpServers.arrmcp = {
url = "http://127.0.0.1:${toString port}/mcp";
url =
if config.services.mcp-lan-access.enable
then "http://${config.services.mcp-lan-access.host}:${toString port}/mcp"
else "http://127.0.0.1:${toString port}/mcp";
timeout = 120;
};
}
+6 -1
View File
@@ -49,10 +49,12 @@
# http://127.0.0.1:8642/p/discord-bridge/v1/chat/completions.
./eternal-terminal
./nextcloud-mcp
./mcp-lan-access
./amazon-mcp
./hinge-auto
./firefox-mcp
# ./stalwart-mcp # FIXME: cargoHash mismatch, needs fix
# ./stalwart-mcp # importé par profiles/tower uniquement (voir le commentaire
# dans le module : sur anywhere la clé sops entre en conflit avec services/stalwart)
./gotify
./zitadel
# ./zitadel-mcp # FIXME: secret zitadel_mcp_env manquant dans zitadel.yml
@@ -66,7 +68,10 @@
./openrouter-mcp
./reactive-resume
./anki-sync-server
./anki-mcp
./winnie
./wise-mcp
./bank-mcp
./llm-steamdeck
# ./kanidm
# ./syncserver
+98
View File
@@ -0,0 +1,98 @@
# services/mcp-lan-access/default.nix
#
# Rend cliquables depuis le Mac de marc les URLs des serveurs MCP HTTP qui
# écoutent en loopback sur tower (nextcloud 8710, anki 3141, arrmcp 6060,
# beeper 23373). Hermes Desktop affiche l'URL de `mcp_servers.<nom>.url` telle
# quelle dans Capabilities -> MCP : en loopback, le clic part sur le localhost
# du Mac et ne peut pas répondre.
#
# Principe : un redirecteur TCP par port, qui écoute sur l'adresse LAN de tower
# et relaie vers 127.0.0.1. systemd-socket-proxyd est un proxy TCP pur : il ne
# touche pas aux en-têtes HTTP, donc les flux SSE / streamable HTTP des serveurs
# MCP passent intacts (pas de buffering nginx à régler).
#
# Sécurité : le firewall NixOS (backend iptables sur tower) n'accepte ces ports
# que depuis `clientAddress` (le Mac). Les ports ne vont JAMAIS dans
# allowedTCPPorts, et les services eux-mêmes restent sur la loopback. Le trafic
# de tower vers sa propre adresse LAN sort par lo (interface de confiance), donc
# Hermes continue de joindre les mêmes URLs sans règle supplémentaire.
#
# Le couple adresse/port est dupliqué dans les URLs MCP : changer `host` ici
# met à jour les quatre URLs du même coup (chaque module service lit cette
# option). Si wlan0 change d'adresse DHCP, mettre `host` à jour et rebuild.
{ config, lib, ... }:
let
cfg = config.services.mcp-lan-access;
forwards = lib.mapAttrsToList (name: port: { inherit name port; }) cfg.endpoints;
unitName = f: "mcp-lan-${f.name}";
in {
options.services.mcp-lan-access = {
enable = lib.mkEnableOption "accès LAN filtré aux endpoints MCP loopback de tower";
host = lib.mkOption {
type = lib.types.str;
default = "192.168.1.44";
description = ''
Adresse LAN de tower sur laquelle les redirecteurs écoutent. Sert aussi
de base aux URLs MCP des modules nextcloud-mcp, arr-mcp, anki-mcp et au
serveur beeper déclaré dans services/hermes.
'';
};
clientAddress = lib.mkOption {
type = lib.types.str;
default = "192.168.1.48";
description = "Seule adresse source que le firewall accepte sur ces ports.";
};
endpoints = lib.mkOption {
type = lib.types.attrsOf lib.types.port;
default = { };
example = {
nextcloud = 8710;
anki = 3141;
};
description = "Attribut nom -> port loopback à republier sur `host`.";
};
};
config = lib.mkIf cfg.enable {
systemd.sockets = lib.listToAttrs (map (f: {
name = unitName f;
value = {
description = "Redirecteur LAN ${cfg.host}:${toString f.port} vers 127.0.0.1:${toString f.port}";
wantedBy = [ "sockets.target" ];
listenStreams = [ "${cfg.host}:${toString f.port}" ];
# wlan0 est en DHCP : FreeBind crée le socket avant que l'adresse existe
# au boot ; il accepte dès que l'adresse est posée. Sans ça, le socket
# unit échoue en "Cannot assign requested address" au démarrage.
socketConfig.FreeBind = true;
};
}) forwards);
systemd.services = lib.listToAttrs (map (f: {
name = unitName f;
value = {
description = "Redirecteur LAN vers MCP ${f.name} (127.0.0.1:${toString f.port})";
requires = [ "${unitName f}.socket" ];
after = [ "${unitName f}.socket" ];
serviceConfig = {
ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd --connections-max=64 127.0.0.1:${toString f.port}";
DynamicUser = true;
PrivateTmp = true;
};
};
}) forwards);
networking.firewall.extraCommands = lib.concatMapStrings (f:
"iptables -w -A nixos-fw -p tcp -s ${cfg.clientAddress} --dport ${toString f.port} -j nixos-fw-accept\n"
) forwards;
assertions = [{
assertion = !(lib.any (f: lib.elem f.port config.networking.firewall.allowedTCPPorts) forwards);
message = "mcp-lan-access : ne pas mettre ces ports dans networking.firewall.allowedTCPPorts ; la règle filtrée par source de ce module suffit.";
}];
};
}
+6 -1
View File
@@ -43,5 +43,10 @@ in {
# Hermes : transport HTTP vers le container loopback.
# Les tools apparaissent préfixés mcp_nextcloud_* au démarrage d'hermes.
services.hermes-agent.mcpServers.nextcloud.url = "http://127.0.0.1:${toString port}/mcp";
# URL LAN (module services/mcp-lan-access) : joignable depuis tower (trafic
# local par lo) et cliquable depuis Hermes Desktop sur le Mac du marc.
services.hermes-agent.mcpServers.nextcloud.url =
if config.services.mcp-lan-access.enable
then "http://${config.services.mcp-lan-access.host}:${toString port}/mcp"
else "http://127.0.0.1:${toString port}/mcp";
}