feat(zitadel-mcp): upstream npm zitadel-mcp-server 34 tools ; nginx-matrix + matrix-conduit modules

This commit is contained in:
Hermes Agent
2026-10-06 21:42:11 -04:00
parent 1ecafcba6a
commit 2c660d8b5d
3 changed files with 449 additions and 350 deletions
+226
View File
@@ -0,0 +1,226 @@
# services/matrix-conduit/default.nix
# Conduit Matrix homeserver for deck (Steam Deck).
# Replaces Synapse on tower for matrix.marcpartensky.com.
# Uses rocksdb (embedded) for simplicity - no external PostgreSQL needed.
# Configures the nixpkgs services.matrix-conduit module and adds helper services.
{ config, pkgs, lib, inputs, ... }: let
dataDir = "/var/lib/matrix-conduit";
serverName = "matrix.marcpartensky.com";
in {
# This module is imported when the user enables services.matrix-conduit.enable
# It provides additional configuration on top of the nixpkgs module
config = lib.mkIf config.services.matrix-conduit.enable {
# Generate registration_token at first boot if not provided via config
systemd.services.matrix-conduit-generate-token = {
description = "Generate Conduit registration token on first boot";
wantedBy = [ "multi-user.target" ];
before = [ "matrix-conduit.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "root";
};
script = ''
set -euo pipefail
TOKEN_FILE="${dataDir}/registration_token"
install -d -m 700 "${dataDir}"
if [ ! -f "$TOKEN_FILE" ]; then
${pkgs.openssl}/bin/openssl rand -hex 32 > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo "Generated registration token at $TOKEN_FILE"
fi
'';
};
# Ensure Conduit starts after token generation
systemd.services.matrix-conduit = {
after = [ "matrix-conduit-generate-token.service" ];
requires = [ "matrix-conduit-generate-token.service" ];
};
# Create Hermes bot account (@hermes:matrix.marcpartensky.com) on first boot
systemd.services.matrix-conduit-register-hermes = {
description = "Create Hermes bot account on Conduit";
wantedBy = [ "multi-user.target" ];
after = [ "matrix-conduit.service" ];
requires = [ "matrix-conduit.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "root";
};
path = [
pkgs.curl
pkgs.jq
pkgs.openssl
pkgs.coreutils
];
script = ''
set -euo pipefail
BASE_URL="http://127.0.0.1:6167"
DATA_DIR="${dataDir}"
PASS_FILE="''${DATA_DIR}/hermes-bot-password"
TOKEN_FILE="/var/lib/hermes/.hermes/matrix-token"
REG_TOKEN_FILE="''${DATA_DIR}/registration_token"
umask 077
install -d -m 700 "''${DATA_DIR}"
# Generate/retrieve bot password
if [ ! -f "$PASS_FILE" ]; then
${pkgs.openssl}/bin/openssl rand -hex 24 > "$PASS_FILE"
fi
PASS=$(cat "$PASS_FILE")
# Read registration token (generated by matrix-conduit-generate-token)
if [ ! -f "$REG_TOKEN_FILE" ]; then
echo "Registration token not found at $REG_TOKEN_FILE"
exit 1
fi
REG_TOKEN=$(cat "$REG_TOKEN_FILE")
# Wait for Conduit to be ready
for i in {1..30}; do
if ${pkgs.curl}/bin/curl -sf "$BASE_URL/_matrix/client/versions" >/dev/null; then
break
fi
sleep 1
done
# First try to login (account may already exist)
for _ in 1 2 3 4 5; do
RESP=$(${pkgs.curl}/bin/curl -sf -X POST \
"$BASE_URL/_matrix/client/v3/login" \
-H 'Content-Type: application/json' \
-d '{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"hermes\"},\"password\":\"'$PASS'\",\"initial_device_display_name\":\"Hermes Agent\"}') && break
sleep 2
done
# If login failed, register using registration_token (Synapse-compatible admin API)
if ! echo "$RESP" | ${pkgs.gnugrep}/bin/grep -q "access_token"; then
NONCE=$(${pkgs.curl}/bin/curl -sf "$BASE_URL/_synapse/admin/v1/register" | ${pkgs.jq}/bin/jq -r .nonce)
MAC=$(printf '%s\0%s\0%s\0%s' "$NONCE" "hermes" "$PASS" "admin" | ${pkgs.openssl}/bin/openssl dgst -sha1 -hmac "$REG_TOKEN" | awk '{print $NF}')
${pkgs.curl}/bin/curl -sf -X POST \
"$BASE_URL/_synapse/admin/v1/register" \
-H 'Content-Type: application/json' \
-d "$(${pkgs.jq}/bin/jq -n --arg n "$NONCE" --arg u "hermes" --arg p "$PASS" --arg m "$MAC" '{nonce: $n, username: $u, password: $p, mac: $m, admin: false, inhibit_login: false}')" \
|| true
sleep 1
# Now login
RESP=$(${pkgs.curl}/bin/curl -sf -X POST \
"$BASE_URL/_matrix/client/v3/login" \
-H 'Content-Type: application/json' \
-d '{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"hermes\"},\"password\":\"'$PASS'\",\"initial_device_display_name\":\"Hermes Agent\"}')
fi
# Extract and store access token
TOKEN=$(printf '%s' "$RESP" | ${pkgs.gnugrep}/bin/grep -o '"access_token":"[^"]*"' | ${pkgs.coreutils}/bin/cut -d '"' -f4)
if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then
${pkgs.coreutils}/bin/mkdir -p /var/lib/hermes/.hermes
printf '%s' "$TOKEN" > "$TOKEN_FILE"
${pkgs.coreutils}/bin/chown hermes:hermes "$TOKEN_FILE"
${pkgs.coreutils}/bin/chmod 600 "$TOKEN_FILE"
echo "Hermes bot token stored at $TOKEN_FILE"
else
echo "Failed to obtain access token for @hermes"
exit 1
fi
'';
};
# Create admin user (@hermes-admin) using registration_token
systemd.services.matrix-conduit-create-admin = {
description = "Create Conduit admin user @hermes-admin";
wantedBy = [ "multi-user.target" ];
after = [ "matrix-conduit.service" ];
requires = [ "matrix-conduit.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "root";
};
path = [
pkgs.curl
pkgs.jq
pkgs.openssl
pkgs.coreutils
];
script = ''
set -euo pipefail
BASE_URL="http://127.0.0.1:6167"
DATA_DIR="${dataDir}"
ADMIN_PASS_FILE="''${DATA_DIR}/hermes-admin-password"
ADMIN_TOKEN_FILE="''${DATA_DIR}/hermes-admin-token"
REG_TOKEN_FILE="''${DATA_DIR}/registration_token"
umask 077
install -d -m 700 "''${DATA_DIR}"
# Read registration token
if [ ! -f "$REG_TOKEN_FILE" ]; then
echo "Registration token not found at $REG_TOKEN_FILE"
exit 1
fi
REG_TOKEN=$(cat "$REG_TOKEN_FILE")
# Wait for Conduit to be ready
for i in {1..30}; do
if ${pkgs.curl}/bin/curl -sf "$BASE_URL/_matrix/client/versions" >/dev/null; then
break
fi
sleep 1
done
# Generate/retrieve admin password
if [ ! -f "$ADMIN_PASS_FILE" ]; then
${pkgs.openssl}/bin/openssl rand -hex 24 > "$ADMIN_PASS_FILE"
fi
ADMIN_PASS=$(cat "$ADMIN_PASS_FILE")
# Check if admin token already exists and is valid
if [ -f "$ADMIN_TOKEN_FILE" ]; then
EXISTING_TOKEN=$(cat "$ADMIN_TOKEN_FILE")
CODE=$(${pkgs.curl}/bin/curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $EXISTING_TOKEN" \
"$BASE_URL/_synapse/admin/v1/server_version" || true)
if [ "$CODE" = "200" ]; then
echo "Existing admin token is valid"
exit 0
fi
fi
# Register @hermes-admin using registration_token (Synapse-compatible admin API)
NONCE=$(${pkgs.curl}/bin/curl -sf "$BASE_URL/_synapse/admin/v1/register" | ${pkgs.jq}/bin/jq -r .nonce)
MAC=$(printf '%s\0%s\0%s\0%s' "$NONCE" "hermes-admin" "$ADMIN_PASS" "admin" | ${pkgs.openssl}/bin/openssl dgst -sha1 -hmac "$REG_TOKEN" | awk '{print $NF}')
RESP=$(${pkgs.curl}/bin/curl -sf -X POST \
"$BASE_URL/_synapse/admin/v1/register" \
-H 'Content-Type: application/json' \
-d "$(${pkgs.jq}/bin/jq -n --arg n "$NONCE" --arg u "hermes-admin" --arg p "$ADMIN_PASS" --arg m "$MAC" '{nonce: $n, username: $u, password: $p, mac: $m, admin: true, inhibit_login: false}')" || true)
# If user exists, just login
if ! echo "$RESP" | ${pkgs.gnugrep}/bin/grep -q "access_token"; then
RESP=$(${pkgs.curl}/bin/curl -sf -X POST \
"$BASE_URL/_matrix/client/v3/login" \
-H 'Content-Type: application/json' \
-d '{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"hermes-admin\"},\"password\":\"'$ADMIN_PASS'\",\"device_id\":\"HERMESADMIN\"}')
fi
ADMIN_TOKEN=$(printf '%s' "$RESP" | ${pkgs.gnugrep}/bin/grep -o '"access_token":"[^"]*"' | ${pkgs.coreutils}/bin/cut -d '"' -f4)
if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then
printf '%s' "$ADMIN_TOKEN" > "$ADMIN_TOKEN_FILE"
chmod 600 "$ADMIN_TOKEN_FILE"
echo "Admin token stored at $ADMIN_TOKEN_FILE"
else
echo "Failed to obtain admin token"
exit 1
fi
'';
};
# Firewall: open federation port (8448) for inbound federation
# Client API (8008/443) is handled by nginx/Pangolin
networking.firewall.allowedTCPPorts = [
8448
];
};
}
+155
View File
@@ -0,0 +1,155 @@
# services/nginx-matrix/default.nix
# Nginx reverse proxy for Matrix client API + federation on deck.
# Terminates TLS (via Pangolin on VPS) and proxies to Conduit on localhost.
{ config, pkgs, lib, inputs, ... }: let
domain = "matrix.marcpartensky.com";
conduitPort = 6167;
federationPort = 8448;
in {
options.services.nginx-matrix = {
enable = lib.mkEnableOption "Nginx reverse proxy for Matrix (Conduit)";
domain = lib.mkOption {
type = lib.types.str;
default = domain;
description = "Matrix server domain";
};
upstream_host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Conduit host";
};
upstream_port = lib.mkOption {
type = lib.types.port;
default = conduitPort;
description = "Conduit client API port";
};
federation_port = lib.mkOption {
type = lib.types.port;
default = federationPort;
description = "Federation port (inbound)";
};
listen_port = lib.mkOption {
type = lib.types.port;
default = 8008;
description = "Local HTTP port for client API (Pangolin terminates TLS)";
};
acme = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable ACME (Let's Encrypt) - false when TLS is terminated by Pangolin";
};
};
config = lib.mkIf config.services.nginx-matrix.enable {
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
clientMaxBodySize = "100M";
# Client API vhost (port 8008) - Pangolin terminates TLS on 443
virtualHosts = {
"${config.services.nginx-matrix.domain}" = {
listen = [
{ addr = "127.0.0.1"; port = config.services.nginx-matrix.listen_port; }
];
serverName = config.services.nginx-matrix.domain;
root = "/var/www/empty";
locations = {
"/_matrix" = {
proxyPass = "http://${config.services.nginx-matrix.upstream_host}:${toString config.services.nginx-matrix.upstream_port}";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 100M;
'';
};
"/.well-known/matrix" = {
proxyPass = "http://${config.services.nginx-matrix.upstream_host}:${toString config.services.nginx-matrix.upstream_port}";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
};
# ACME disabled - TLS handled by Pangolin on VPS
forceSSL = config.services.nginx-matrix.acme;
enableACME = config.services.nginx-matrix.acme;
};
};
};
# Federation vhost (port 8448) - separate server block for inbound federation
# Note: Federation uses the same domain but different port
# Pangolin on VPS should route federation traffic to deck:8448
systemd.services.nginx-matrix-federation = {
description = "Nginx federation proxy for Matrix";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
requires = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStartPre = [
"${pkgs.nginx}/bin/nginx -t"
];
};
# The actual federation listener is configured via nginx virtualHosts below
# This service just ensures nginx is reloaded after config changes
};
# Add federation listener to nginx
services.nginx.virtualHosts."${config.services.nginx-matrix.domain}-federation" = {
listen = [
{ addr = "0.0.0.0"; port = config.services.nginx-matrix.federation_port; }
];
serverName = config.services.nginx-matrix.domain;
root = "/var/www/empty";
locations = {
"/_matrix" = {
proxyPass = "http://${config.services.nginx-matrix.upstream_host}:${toString config.services.nginx-matrix.upstream_port}";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 100M;
'';
};
"/.well-known/matrix" = {
proxyPass = "http://${config.services.nginx-matrix.upstream_host}:${toString config.services.nginx-matrix.upstream_port}";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
};
# No ACME - TLS terminated by Pangolin
forceSSL = false;
enableACME = false;
};
# Open firewall for federation port (inbound from other servers)
networking.firewall.allowedTCPPorts = [
config.services.nginx-matrix.federation_port
];
# Ensure www directory exists
systemd.tmpfiles.rules = [
"d /var/www/empty 0755 root root -"
];
};
}
+68 -350
View File
@@ -1,359 +1,73 @@
# services/zitadel-mcp/default.nix
# Serveur MCP Zitadel pour hermes : création/gestion des comptes (humains) de
# ses potes via l'API Zitadel, transport stdio (hermes spawn le subprocess).
#
# Auth : Personal Access Token d'un service user Zitadel. Le token vit dans un
# fichier sops (secrets/zitadel.yml), jamais dans le nix store ; le serveur le
# lit à chaque appel.
# Upstream: https://github.com/takleb3rry/zitadel-mcp (MIT, npm: zitadel-mcp-server)
# 34 tools live by default (verified with a real tools/list handshake): users,
# projects, applications (create_oidc_app / update_app / list_apps / get_app),
# roles, org managers, service accounts, login policy, login texts, plus
# idempotent provisioning and offboarding. The README advertises 37: the three
# extra ones are gated, zitadel_set_self_registration behind
# ZITADEL_ENABLE_LOGIN_POLICY_WRITE and the two portal_* tools behind
# PORTAL_DATABASE_URL, neither of which is set here.
#
# Créer le PAT dans la console (https://auth.marcpartensky.com) :
# Users -> Service Users -> New (Access Token Type = Bearer)
# puis Organisation ZITADEL -> Managers -> ajouter le service user
# -> rôle ORG_OWNER (ou org user manager pour un scope plus étroit)
# puis Service User -> Personal Access Tokens -> New, copier le token.
# This replaces a hand-written Python server that lived here before. That one
# exposed 15 tools with no application management at all, and its PAT was a
# never-filled placeholder, so every call answered HTTP 401.
#
# Debug : tester le serveur à la main en tant que hermes
# Auth is a service-account JSON key (signed JWT), not a PAT. Create it in the
# console: Users -> Service Users -> New with Access Token Type "Bearer", then
# on the service user Keys -> New -> JSON, then Organization -> Authorizations
# to grant the account ORG_OWNER. Give it ORG_OWNER rather than
# ORG_USER_MANAGER: ORG_USER_MANAGER is enough to create users and assign
# existing project roles, but creating project roles and managing applications
# needs ORG_OWNER.
#
# The four values plus the issuer live in ONE sops secret holding a dotenv
# file, referenced through DOTENV_CONFIG_PATH, so nothing provider-specific
# stays in this module.
#
# Debug by hand, as hermes:
# { printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 5; } \
# | ZITADEL_PAT_FILE=... /nix/store/.../bin/zitadel-mcp
# (garder stdin ouvert : un EOF tue la requête en vol)
# | DOTENV_CONFIG_PATH=... /nix/store/.../bin/zitadel-mcp
# (keep stdin open: an EOF kills the request in flight)
{pkgs, config, ...}: let
zitadel-mcp =
pkgs.writers.writePython3Bin "zitadel-mcp" {
libraries = [pkgs.python3Packages.mcp];
flakeIgnore = ["E501" "W503" "W504"];
} ''
import json
import os
import urllib.error
import urllib.parse
import urllib.request
try:
from mcp.server import MCPServer # mcp >= 2
except ImportError:
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
BASE = os.environ.get("ZITADEL_URL", "https://auth.marcpartensky.com").rstrip("/")
ORG_ID = os.environ.get("ZITADEL_ORG_ID", "") # optionnel : x-zitadel-orgid
STATES = {
0: "unspecified",
1: "active",
2: "inactive",
3: "deleted",
4: "locked",
5: "suspend",
6: "initial (invité, pas encore activé)",
}
mcp = MCPServer("zitadel")
def _pat():
path = os.environ.get("ZITADEL_PAT_FILE")
if path and os.path.exists(path):
with open(path) as fh:
return fh.read().strip()
return os.environ.get("ZITADEL_PAT", "").strip()
def api(method, path, body=None):
"""Appel API Zitadel. Lève RuntimeError avec le corps d'erreur brut."""
token = _pat()
if not token:
raise RuntimeError(
"Aucun PAT Zitadel : renseigner ZITADEL_PAT_FILE (secret sops) "
"ou ZITADEL_PAT."
)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json")
if ORG_ID:
req.add_header("x-zitadel-orgid", ORG_ID)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw.strip() else {})
except urllib.error.HTTPError as err:
raw = err.read().decode()
try:
detail = json.dumps(json.loads(raw), ensure_ascii=False)
except Exception:
detail = raw
raise RuntimeError(f"HTTP {err.code} sur {method} {path} : {detail[:900]}") from None
def _view(user):
"""Réduit un UserView v1 aux champs utiles."""
state = user.get("state")
return {
"id": user.get("id"),
"login": user.get("preferredLoginName") or user.get("username"),
"username": user.get("username"),
"email": user.get("email"),
"email_verified": user.get("isEmailVerified"),
"nom": user.get("displayName"),
"prenom": user.get("firstName"),
"nom_famille": user.get("lastName"),
"etat": STATES.get(state, state) if isinstance(state, int) else state,
"type": user.get("type"),
"derniere_connexion": user.get("lastLogin"),
"creation": (user.get("details") or {}).get("creationDate"),
}
def _search_users(**kwargs):
body = {"offset": 0, "limit": kwargs.pop("limit", 50)}
queries = kwargs.pop("queries", None)
if queries:
body["queries"] = queries
_, resp = api("POST", "/management/v1/users/_search", body)
return resp
@mcp.tool()
def status() -> dict:
"""État de l'instance Zitadel et de l'authentification du serveur.
Renvoie l'org courante et le nombre de comptes humains. Sert de test
de vie du PAT (renvoie l'erreur HTTP brute si le token est invalide
ou sans droits).
"""
_, org = api("GET", "/management/v1/orgs/me")
teams = _search_users(limit=1)
return {
"instance": BASE,
"org": {
"id": (org.get("org") or {}).get("id"),
"nom": (org.get("org") or {}).get("name"),
"domaine": (org.get("org") or {}).get("primaryDomain"),
},
"comptes_humains": (teams.get("details") or {}).get("totalResult"),
}
@mcp.tool()
def list_users(filtre: str = "", limit: int = 50) -> list:
"""Liste les comptes (humains et techniques) de l'org.
filtre : sous-chaîne cherchée dans le login ou l'email (casse
ignorée). Vide = tous.
"""
users = (_search_users(limit=max(limit, 1)).get("result") or [])
views = [_view(u) for u in users]
if filtre:
needle = filtre.lower()
views = [
v
for v in views
if any(
needle in (v[champ] or "").lower()
for champ in ("login", "email", "nom")
)
]
return views
@mcp.tool()
def find_user(email_ou_login: str) -> dict:
"""Trouve un compte par email ou login (égalité, casse ignorée)."""
for query in (
{"emailQuery": {"emailAddress": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
{"userNameQuery": {"userName": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
):
resp = _search_users(limit=5, queries=[query])
found = resp.get("result") or []
if found:
return _view(found[0])
return {"trouve": False, "recherche": email_ou_login}
@mcp.tool()
def get_user(user_id: str) -> dict:
"""Détail complet d'un compte par son user_id (id technique Zitadel)."""
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}")
return _view(resp.get("user") or {})
@mcp.tool()
def create_friend(
email: str,
prenom: str,
nom: str,
username: str = "",
envoyer_invitation: bool = True,
mot_de_passe: str = "",
langue: str = "fr",
) -> dict:
"""Crée un compte humain pour un pote et lui envoie l'invitation.
Par défaut : email marqué vérifié (évite un second mail de
vérification) puis envoi du mail d'invitation Zitadel ; le pote
choisit lui-même son mot de passe/passkey, l'agent ne le voit jamais.
Décocher envoyer_invitation n'a de sens que si mot_de_passe est
fourni (compte utilisable tout de suite, mot de passe à transmettre
hors ligne).
"""
if not envoyer_invitation and not mot_de_passe:
return {
"erreur": "sans invitation il faut fournir mot_de_passe, sinon "
"le compte reste en état initial et inutilisable"
}
body = {
"username": username or email,
"profile": {"givenName": prenom, "familyName": nom, "preferredLanguage": langue},
"email": {"email": email, "isVerified": True},
}
if mot_de_passe:
body["password"] = {"password": mot_de_passe, "changeRequired": False}
_, created = api("POST", "/v2/users/human", body)
user_id = created.get("userId")
out = {"id": user_id, "email": email, "invitation": "non demandée"}
if envoyer_invitation:
_, invite = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
{"sendCode": {}},
)
out["invitation"] = "mail envoyé"
return out
@mcp.tool()
def invite_user(user_id: str) -> dict:
"""(Re)envoie le mail d'invitation à un compte existant, y compris un
compte resté en état initial jamais activé. Écrase l'ancien code."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
{"sendCode": {}},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def set_password(user_id: str, mot_de_passe: str, changement_obligatoire: bool = False) -> dict:
"""Fixe le mot de passe d'un compte (l'agent le voit passer : à
réserver aux cas où l'invitation par mail ne convient pas)."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/password",
{
"newPassword": {
"password": mot_de_passe,
"changeRequired": changement_obligatoire,
},
"verification": {},
},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def reset_password(user_id: str) -> dict:
"""Envoie un mail de réinitialisation de mot de passe (le pote choisit
lui-même le nouveau)."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/password/reset",
{"sendLink": {}},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def set_active(user_id: str, actif: bool) -> dict:
"""Active ou désactive un compte. Désactivé = ne peut plus se
connecter, données conservées. Réversible."""
action = "reactivate" if actif else "deactivate"
_, resp = api("POST", f"/v2/users/{urllib.parse.quote(user_id)}/{action}")
return {"ok": True, "etat": "actif" if actif else "désactivé", "details": resp.get("details")}
@mcp.tool()
def delete_user(user_id: str) -> dict:
"""Supprime un compte (irréversible). Préférer set_active(False)."""
api("DELETE", f"/v2/users/{urllib.parse.quote(user_id)}")
return {"ok": True, "supprime": user_id}
@mcp.tool()
def list_projects() -> list:
"""Projets Zitadel de l'org (les applications à rôle, ex. Nextcloud,
Portainer) : à croiser avec list_project_roles."""
_, resp = api("POST", "/management/v1/projects/_search", {"offset": 0, "limit": 50})
return [
{"id": p.get("id"), "nom": p.get("name"), "etat": p.get("state")}
for p in (resp.get("result") or [])
]
@mcp.tool()
def list_project_roles(project_id: str) -> list:
"""Rôles disponibles dans un projet (à passer à grant_roles)."""
_, resp = api(
"POST",
f"/management/v1/projects/{urllib.parse.quote(project_id)}/roles/_search",
{"offset": 0, "limit": 100},
)
return [
{
"cle": r.get("key"),
"nom": r.get("displayName") or (r.get("details") or {}).get("displayName"),
"groupe": r.get("group"),
}
for r in (resp.get("result") or [])
]
@mcp.tool()
def grant_roles(user_id: str, project_id: str, roles: list[str]) -> dict:
"""Donne des rôles projet à un compte sur un projet (accès applicatif)."""
_, resp = api(
"POST",
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants",
{"projectId": project_id, "roleKeys": roles},
)
return {"ok": True, "grant_id": resp.get("userGrantId"), "roles": roles}
@mcp.tool()
def list_user_grants(user_id: str) -> list:
"""Rôles projet déjà accordés à un compte."""
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}/grants")
out = []
for g in resp.get("userGrantList") or resp.get("result") or []:
out.append(
{
"grant_id": g.get("id"),
"projet": g.get("projectName") or g.get("projectId"),
"roles": g.get("roleKeys"),
"etat": g.get("state"),
}
)
return out
@mcp.tool()
def revoke_grant(user_id: str, grant_id: str) -> dict:
"""Retire un rôle projet (voir list_user_grants pour le grant_id)."""
api(
"DELETE",
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants/{urllib.parse.quote(grant_id)}",
)
return {"ok": True, "retire": grant_id}
if __name__ == "__main__":
mcp.run()
# The published npm tarball already ships the compiled build/, so this
# derivation only installs the runtime dependencies: there is nothing to
# compile, hence dontNpmBuild.
#
# Upstream ships no package-lock.json (the repository has none). The one
# committed next to this file was generated with
# `npm install --package-lock-only` from the published package.json,
# dependencies only, so the dependency set is pinned without dragging in the
# devDependencies (tsc, vitest, tsx) that are useless here. Regenerate it when
# bumping the version.
zitadel-mcp = pkgs.buildNpmPackage {
pname = "zitadel-mcp-server";
version = "2.0.0";
src = pkgs.fetchurl {
url = "https://registry.npmjs.org/zitadel-mcp-server/-/zitadel-mcp-server-2.0.0.tgz";
hash = "sha256-+PvZY0GYPxis8VxOR0ejSh+gqn4uKqH9ANFGrqaT5gk=";
};
npmDepsHash = "sha256-i6lYVprSJUkGmZupmbuSykK29wBTOsJOk0NVgj3V3dc=";
dontNpmBuild = true;
postPatch = ''
cp ${./package-lock.json} package-lock.json
# The committed lock covers the runtime dependencies only (npm refuses to
# compose a lock for this manifest once devDependencies are present), and
# npm ci refuses a manifest that disagrees with its lock. Drop them from
# the manifest: nothing is compiled here, so they were dead weight.
${pkgs.jq}/bin/jq 'del(.devDependencies)' package.json > package.json.tmp
mv package.json.tmp package.json
'';
};
in {
# Le PAT vit dans secrets/zitadel.yml (fichier sops distinct : tower.yml n'est
# pas modifiable par hermes, sa clé MAC exigerait la clé age privée).
sops.secrets."zitadel_mcp_pat" = {
# Content of this secret (a dotenv file):
# ZITADEL_ISSUER=https://<ton instance>
# ZITADEL_SERVICE_ACCOUNT_USER_ID=...
# ZITADEL_SERVICE_ACCOUNT_KEY_ID=...
# ZITADEL_SERVICE_ACCOUNT_PRIVATE_KEY=<RSA en base64, le champ "key" du JSON>
# ZITADEL_ORG_ID=...
# secrets/zitadel.yml rather than tower.yml: hermes can write this one.
sops.secrets."zitadel_mcp_env" = {
sopsFile = ../../secrets/zitadel.yml;
owner = "hermes";
group = "hermes";
@@ -364,8 +78,12 @@ in {
services.hermes-agent.mcpServers.zitadel = {
command = "${zitadel-mcp}/bin/zitadel-mcp";
env = {
ZITADEL_URL = "https://auth.marcpartensky.com";
ZITADEL_PAT_FILE = config.sops.secrets."zitadel_mcp_pat".path;
DOTENV_CONFIG_PATH = config.sops.secrets."zitadel_mcp_env".path;
LOG_LEVEL = "WARN";
# Read-only first: the write tools stay visible but refuse to mutate, so
# the tool list and the permissions can be checked before granting
# anything. Drop the line (or set "false") once that is done.
ZITADEL_READ_ONLY = "true";
};
};
}