diff --git a/profiles/tower/configuration.nix b/profiles/tower/configuration.nix
index 43072d8..3f66041 100644
--- a/profiles/tower/configuration.nix
+++ b/profiles/tower/configuration.nix
@@ -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)
# ========================================================================
diff --git a/services/anki-mcp/bootstrap/__init__.py b/services/anki-mcp/bootstrap/__init__.py
new file mode 100644
index 0000000..02f8fc0
--- /dev/null
+++ b/services/anki-mcp/bootstrap/__init__.py
@@ -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)
diff --git a/services/anki-mcp/bootstrap/manifest.json b/services/anki-mcp/bootstrap/manifest.json
new file mode 100644
index 0000000..b36af7a
--- /dev/null
+++ b/services/anki-mcp/bootstrap/manifest.json
@@ -0,0 +1,6 @@
+{
+ "package": "ankimcp_sync_bootstrap",
+ "name": "AnkiMCP sync bootstrap",
+ "mod": 0,
+ "conflicts": []
+}
diff --git a/services/anki-mcp/default.nix b/services/anki-mcp/default.nix
new file mode 100644
index 0000000..fae960e
--- /dev/null
+++ b/services/anki-mcp/default.nix
@@ -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 /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 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;
+ };
+ };
+}
diff --git a/services/anki-mcp/seed-prefs.py b/services/anki-mcp/seed-prefs.py
new file mode 100644
index 0000000..72e095b
--- /dev/null
+++ b/services/anki-mcp/seed-prefs.py
@@ -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())
diff --git a/services/arr-mcp/default.nix b/services/arr-mcp/default.nix
index 7b2efe1..28e31b3 100644
--- a/services/arr-mcp/default.nix
+++ b/services/arr-mcp/default.nix
@@ -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;
};
}
diff --git a/services/default.nix b/services/default.nix
index 33d6b65..98e50c2 100644
--- a/services/default.nix
+++ b/services/default.nix
@@ -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
diff --git a/services/mcp-lan-access/default.nix b/services/mcp-lan-access/default.nix
new file mode 100644
index 0000000..fcc9855
--- /dev/null
+++ b/services/mcp-lan-access/default.nix
@@ -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..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.";
+ }];
+ };
+}
diff --git a/services/nextcloud-mcp/default.nix b/services/nextcloud-mcp/default.nix
index 1b1764f..b8f6d2a 100644
--- a/services/nextcloud-mcp/default.nix
+++ b/services/nextcloud-mcp/default.nix
@@ -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";
}