feat(zitadel-mcp): upstream npm zitadel-mcp-server 34 tools ; nginx-matrix + matrix-conduit modules
This commit is contained in:
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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 -"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user