fix(mcp): intégration réelle passerelle MCP Portainer (image+auth) (v0.34.0)

Image portainer-mcp 0.6.0 (inexistant) -> 2.42.6. Passerelle HTTP :17717/mcp
attend Bearer (secret passerelle) + X-Portainer-API-Key (clé API restreinte
chlova) : ajout config.portainerApiKey + McpServerConfig.extraHeaders, backend
envoie les deux. socket-proxy supprimé (plus de socket monté). Compose prod,
.env.example, deploy.md à jour. Typecheck + 78 tests verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
This commit is contained in:
Kantin-Petit
2026-06-23 13:50:09 +02:00
parent 4e23828dae
commit 2d3c944699
8 changed files with 74 additions and 51 deletions
+9 -4
View File
@@ -19,13 +19,18 @@ OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-call
MCP_N8N_URL=http://n8n:5678/mcp-server/http # ou https://<n8n-public>/mcp-server/http
MCP_N8N_AUTH_TOKEN= # SECRET — "MCP Access Token" n8n (Bearer)
# ── MCP Portainer (portainer/portainer-mcp) ────────────────────────────
PORTAINER_URL=https://portainer:9443 # interne uniquement
PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RESTREINTE
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) ────────────
PORTAINER_URL=http://portainer:9000 # API Portainer, interne (même hôte)
# Secret de PASSERELLE (front-gate) : même valeur côté backend et côté serveur
# MCP. Envoyé en Authorization: Bearer. N'autorise que l'accès à la passerelle.
PORTAINER_MCP_AUTH_TOKEN= # SECRET — secret de passerelle (au choix)
# Clé API Portainer de l'utilisateur `chlova` (RESTREINTE) : envoyée en header
# X-Portainer-API-Key. C'est elle qui cloisonne l'accès réel de CHLOVA.
PORTAINER_API_KEY= # SECRET — Access token Portainer de chlova
# Phase 1 : DOIT rester true (le boot échoue sinon). Phase 2 : peut passer false
# pour autoriser les écritures Portainer (sous gatekeeper + review).
PORTAINER_READ_ONLY=true
MCP_PORTAINER_URL=http://mcp-portainer:3000
MCP_PORTAINER_URL=http://mcp-portainer:17717/mcp
# ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une
+22
View File
@@ -6,6 +6,28 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
## [0.34.0] — 2026-06-23 — intégration réelle passerelle MCP Portainer
### Fixed
- **Image MCP Portainer** : `portainer/portainer-mcp:0.6.0` (tag inexistant)
**`2.42.6`** (confirmé Docker Hub). Découvert au déploiement réel (pull 404).
- **Modèle d'auth de la passerelle MCP Portainer** corrigé : le serveur HTTP
(port **17717**, endpoint **`/mcp`**) attend `Authorization: Bearer <secret de
passerelle>` **et** `X-Portainer-API-Key: <clé API restreinte>`. Le backend
n'envoyait que le Bearer.
### Added
- `config.portainerApiKey` (`PORTAINER_API_KEY`, SECRET) : clé API Portainer
restreinte de `chlova`, envoyée en `X-Portainer-API-Key` — c'est elle qui
cloisonne l'accès. `PORTAINER_MCP_AUTH_TOKEN` devient le secret de passerelle.
- `McpServerConfig.extraHeaders` : headers additionnels par serveur MCP.
### Changed
- `docker-compose.prod.yml` : `mcp-portainer` reconfiguré (2.42.6, :17717,
`PORTAINER_MCP_ALLOWED_HOSTS`, plaintext HTTP interne, `PORTAINER_TLS_VERIFY=0`,
`PORTAINER_READ_ONLY`). **`socket-proxy` supprimé** (la passerelle parle à
l'API Portainer, plus aucun socket Docker monté = surface réduite).
`MCP_PORTAINER_URL``:17717/mcp`. Backend reçoit `PORTAINER_API_KEY`.
- `.env.example` + `docs/deploy.md` (§3a, tableau variables) mis à jour.
- Typecheck vert, 78 tests verts.
## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova)
### Added
- **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte
+9 -5
View File
@@ -58,13 +58,16 @@ git push gitea main # auth : <admin> + token de l'étape 1.3
> Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe :
> CHLOVA n'accède qu'à ses ressources, tokens à portée minimale.
### 3a. Portainer — user `chlova` + token
### 3a. Portainer — user `chlova` + clé API + secret de passerelle
1. **Users → Add user** : `chlova`, non-admin.
2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC
par environnement, pas par stack ; le vrai verrou Phase 1 reste
`PORTAINER_READ_ONLY=true`.)*
par environnement, pas par stack ; verrous additionnels :
`PORTAINER_READ_ONLY=true` côté serveur MCP **et** la clé API étant celle de
`chlova`, l'accès réel est borné à ce que `chlova` peut voir.)*
3. Connecté en `chlova`**My account → Access tokens → Add token**
`PORTAINER_MCP_AUTH_TOKEN`.
`PORTAINER_API_KEY` (envoyé en `X-Portainer-API-Key`, cloisonne l'accès).
4. **`PORTAINER_MCP_AUTH_TOKEN`** = secret de **passerelle** au choix (chaîne
aléatoire) ; même valeur dans le stack `mcp-portainer` et côté backend.
### 3b. n8n-chlova — MCP token
1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire.
@@ -96,7 +99,8 @@ l'opérateur, jamais par l'agent).
| `OLLAMA_API_KEY` | clé Ollama cloud | **oui** |
| `OLLAMA_MODEL` | `qwen3:cloud` | non |
| `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** |
| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **oui** |
| `PORTAINER_MCP_AUTH_TOKEN` | secret de passerelle (§3a.4) | **oui** |
| `PORTAINER_API_KEY` | clé API Portainer de chlova (§3a.3) | **oui** |
| `CHLOVA_ADMIN_USER` | ex. `kantin` | non |
| `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** |
| `CHLOVA_TOTP_SECRET` | (§4) | **oui** |
+14 -37
View File
@@ -32,44 +32,20 @@ services:
- chlova-egress
# AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé.
# ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ────────
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3.0
restart: unless-stopped
environment:
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
VOLUMES: 1
SERVICES: 1
TASKS: 1
NODES: 1
INFO: 1
VERSION: 1
POST: 0
EXEC: 0
AUTH: 0
SECRETS: 0
CONFIGS: 0
BUILD: 0
COMMIT: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- chlova-internal
# AUCUN port publié.
# ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ──────
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) — read-only P1
# Parle à l'API Portainer (pas au socket Docker). Le backend s'y connecte en
# HTTP sur :17717/mcp avec le secret de passerelle (Bearer) + la clé API
# restreinte de chlova (X-Portainer-API-Key).
mcp-portainer:
image: portainer/portainer-mcp:0.6.0
image: portainer/portainer-mcp:2.42.6
restart: unless-stopped
environment:
PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false
DOCKER_HOST: tcp://socket-proxy:2375
depends_on:
- socket-proxy
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (partagé avec le backend)
PORTAINER_MCP_ALLOWED_HOSTS: mcp-portainer:17717 # hôte par lequel le backend appelle
PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP: "1" # HTTP interne (réseau Docker privé)
PORTAINER_TLS_VERIFY: "0" # Portainer interne en HTTP : pas de TLS
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : GET/HEAD seulement (défense en profondeur)
networks:
- chlova-internal # joignable par le backend
- proxy # joint le serveur Portainer (http://portainer:9000)
@@ -95,9 +71,10 @@ services:
# — MCP n8n (natif, interne via réseau proxy) —
MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http}
MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis}
# — MCP Portainer (sidecar) —
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis}
# — MCP Portainer (passerelle) —
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:17717/mcp}
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (= côté sidecar)
PORTAINER_API_KEY: ${PORTAINER_API_KEY:?requis} # clé API Portainer restreinte de chlova
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true}
# — Alertes (Phase 3) : vide = log-only —
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
+8 -1
View File
@@ -35,9 +35,14 @@ const schema = z.object({
mcpN8nUrl: z.string().url(),
mcpN8nAuthToken: nonEmpty, // SECRET
// MCP Portainer
// MCP Portainer (passerelle HTTP portainer/portainer-mcp)
mcpPortainerUrl: z.string().url(),
// Secret de PASSERELLE (front-gate) partagé avec le serveur MCP : envoyé en
// `Authorization: Bearer`. N'autorise QUE l'accès à la passerelle.
portainerMcpAuthToken: nonEmpty, // SECRET
// Clé API Portainer de l'utilisateur `chlova` (restreinte) : envoyée en header
// `X-Portainer-API-Key`. C'est ELLE qui cloisonne ce que CHLOVA peut voir/faire.
portainerApiKey: nonEmpty, // SECRET
// Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2
// n'est pas explicitement activée (voir assertReadOnlyPhase()).
portainerReadOnly: z
@@ -99,6 +104,7 @@ const SECRET_KEYS = new Set<keyof Config>([
"ollamaApiKey",
"mcpN8nAuthToken",
"portainerMcpAuthToken",
"portainerApiKey",
"telegramBotToken",
"alertWebhookUrl",
"adminPasswordHash",
@@ -118,6 +124,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN,
mcpPortainerUrl: env.MCP_PORTAINER_URL,
portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN,
portainerApiKey: env.PORTAINER_API_KEY,
portainerReadOnly: env.PORTAINER_READ_ONLY,
telegramBotToken: env.TELEGRAM_BOT_TOKEN,
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
+3 -1
View File
@@ -49,7 +49,9 @@ async function main(): Promise<void> {
await registry.connect({
name: "portainer",
url: cfg.mcpPortainerUrl,
authToken: cfg.portainerMcpAuthToken,
authToken: cfg.portainerMcpAuthToken, // secret de passerelle (Bearer)
// Clé API Portainer restreinte de `chlova` : cloisonne l'accès réel.
extraHeaders: { "X-Portainer-API-Key": cfg.portainerApiKey },
});
// ── Outils + Guard, selon la phase ──────────────────────────────────────
+6 -1
View File
@@ -20,6 +20,8 @@ export interface McpServerConfig {
name: string;
url: string;
authToken: string;
/** Headers additionnels (ex. X-Portainer-API-Key pour la passerelle Portainer). */
extraHeaders?: Record<string, string>;
}
interface ConnectedServer {
@@ -36,7 +38,10 @@ export class McpRegistry {
async connect(cfg: McpServerConfig): Promise<void> {
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: {
headers: { authorization: `Bearer ${cfg.authToken}` },
headers: {
authorization: `Bearer ${cfg.authToken}`,
...cfg.extraHeaders,
},
},
});
const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" });
+3 -2
View File
@@ -12,8 +12,9 @@ const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_MODEL: "qwen3:cloud",
MCP_N8N_URL: "http://mcp-n8n:3000",
MCP_N8N_AUTH_TOKEN: "secret-n8n",
MCP_PORTAINER_URL: "http://mcp-portainer:3000",
PORTAINER_MCP_AUTH_TOKEN: "secret-portainer",
MCP_PORTAINER_URL: "http://mcp-portainer:17717/mcp",
PORTAINER_MCP_AUTH_TOKEN: "secret-gate",
PORTAINER_API_KEY: "secret-portainer-apikey",
PORTAINER_READ_ONLY: "true",
TELEGRAM_BOT_TOKEN: "secret-tg",
TELEGRAM_ALLOWED_USER_IDS: "111, 222",