leadtech-bmad-mcp: serveur MCP central HTTP via sidecar Tailscale

Transforme le MCP leadtech-bmad de stdio local en service HTTP central
conteneurisé, accessible depuis tout périphérique du tailnet.

- server.main(): transport piloté par LEADTECH_MCP_TRANSPORT (stdio par
  défaut → aucune régression locale; streamable-http pour le central).
  Host/port via LEADTECH_MCP_HOST/_PORT.
- _build_transport_security(): whitelist d'hôtes via LEADTECH_MCP_ALLOWED_HOSTS
  pour lever la protection anti-DNS-rebinding (HTTP 421) derrière le sidecar.
- Dockerfile (python:3.11-slim, build index au démarrage, lance le serveur HTTP).
- docker-compose.yml: service mcp (réseau interne, aucun port publié) +
  sidecar tailscale (tailscale serve TLS MagicDNS). user 1000:1000 pour
  l'écriture dans le bind-mont. ALLOW_WRITE=1 sur l'instance centrale.
- tailscale/serve.json, .env.example, mcp.config.http.example.json.
- .gitignore: ignore le .env (secrets), garde .env.example.
- docs/design_nuc_tailscale.md: statut passé à IMPLÉMENTÉ + URL réelle.

Validé: handshake MCP initialize HTTPS via tailnet → 200, 7 tools listables,
écriture 95_a_capitaliser.md confirmée, 79 tests verts.
This commit is contained in:
MaksTinyWorkshop
2026-06-25 10:30:53 +02:00
parent 2fa34f0f6f
commit 1c876309f1
8 changed files with 240 additions and 13 deletions
+3
View File
@@ -14,3 +14,6 @@ dist/
# Leadtech MCP — index local généré (regenerable via leadtech-bmad-build-index) # Leadtech MCP — index local généré (regenerable via leadtech-bmad-build-index)
.leadtech_mcp_index.json .leadtech_mcp_index.json
# Leadtech MCP — secrets du déploiement central (le .env.example reste versionné)
.env
+10
View File
@@ -0,0 +1,10 @@
# leadtech-bmad-mcp — secrets du déploiement central (à copier en .env, non versionné).
#
# Auth key Tailscale pour le sidecar.
# Générer dans l'admin console Tailscale : https://login.tailscale.com/admin/settings/keys
# - Reusable : OUI (sinon ré-enrôlement impossible après recreate)
# - Ephemeral : NON (sinon le nœud disparaît à chaque arrêt → pollution tailnet)
# - Tags : optionnel (ex tag:mcp) selon ta politique ACL
# Après le 1er `up`, pense à DISABLE KEY EXPIRY sur le nœud `leadtech-mcp`
# dans l'admin console (sinon ré-auth tous les ~90j).
LEADTECH_TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx
+32
View File
@@ -0,0 +1,32 @@
# leadtech-bmad-mcp — image du serveur MCP central (transport streamable-http)
#
# Le code Python est copié dans l'image. La base de connaissance Lead_tech
# (LEADTECH_ROOT) n'est PAS copiée : elle est bind-montée au runtime depuis
# /srv/helpers/_Assistant_Lead_Tech sur le NUC (source de vérité = clone Git).
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Dépendances d'abord (cache de build) puis le code.
COPY pyproject.toml README.md ./
COPY src ./src
COPY config ./config
RUN pip install -e .
# Transport HTTP par défaut dans l'image ; surchargé par compose si besoin.
ENV LEADTECH_MCP_TRANSPORT=streamable-http \
LEADTECH_MCP_HOST=0.0.0.0 \
LEADTECH_MCP_PORT=8080 \
LEADTECH_ROOT=/leadtech
EXPOSE 8080
# L'index est (re)construit au démarrage car il dépend du contenu bind-monté,
# pas du code de l'image. Puis on lance le serveur MCP.
CMD ["sh", "-c", "leadtech-bmad-build-index && exec leadtech-bmad-mcp"]
+81
View File
@@ -0,0 +1,81 @@
# leadtech-bmad-mcp — MCP central exposé au tailnet via sidecar Tailscale.
#
# Architecture (cf. docs/design_nuc_tailscale.md) :
#
# tailnet ──https──► [tailscale sidecar] ──http (réseau interne)──► [mcp:8080]
#
# - Le serveur MCP n'écoute QUE sur le réseau Docker interne (aucun port publié
# sur l'hôte → aucune exposition LAN/WAN).
# - Le sidecar rejoint le tailnet sous le hostname `leadtech-mcp` et termine TLS
# via `tailscale serve` (cert MagicDNS automatique).
# - URL client finale : https://leadtech-mcp.wyvern-snapper.ts.net/mcp
#
# Prérequis : copier .env.example en .env et y mettre LEADTECH_TS_AUTHKEY
# (auth key Tailscale Reusable, NON-Ephemeral — voir README / design doc).
name: leadtech-mcp
services:
mcp:
build:
context: .
dockerfile: Dockerfile
image: leadtech-bmad-mcp:local
container_name: leadtech-bmad-mcp
restart: unless-stopped
# Tourne en max:max (uid 1000) pour écrire l'index + 95_a_capitaliser.md
# dans le bind-mont sans casser les permissions du repo Git hôte.
user: "1000:1000"
environment:
LEADTECH_ROOT: /leadtech
LEADTECH_MCP_TRANSPORT: streamable-http
LEADTECH_MCP_HOST: 0.0.0.0
LEADTECH_MCP_PORT: "8080"
# Le sidecar termine TLS et forwarde le Host tailnet ; sans whitelist,
# la protection anti-DNS-rebinding renvoie 421. Inclut le hostname interne
# (mcp:8080) au cas où le proxy préserve l'autorité d'origine.
LEADTECH_MCP_ALLOWED_HOSTS: "leadtech-mcp.wyvern-snapper.ts.net,leadtech-mcp.wyvern-snapper.ts.net:443,mcp:8080,mcp"
# Instance centrale = seule à écrire (capitalisation centralisée).
LEADTECH_MCP_ALLOW_WRITE: "1"
volumes:
# Source de vérité : le clone Git Lead_tech (lecture + écriture buffer/index).
- /srv/helpers/_Assistant_Lead_Tech:/leadtech
# Les projets cibles de route_to_project_memory (mêmes chemins que sur l'hôte).
- /srv/projects:/srv/projects
networks:
- internal
# PAS de `ports:` — jamais exposé hors du réseau Docker interne.
tailscale:
image: tailscale/tailscale:v1.98.3
container_name: leadtech-mcp-ts
restart: unless-stopped
# ⚠ PAS de `hostname:` ici (collision DNS Docker service vs hostname,
# cf. knowledge/infra/risques/docker.md). TS_HOSTNAME suffit côté tailnet.
depends_on:
- mcp
environment:
TS_AUTHKEY: ${LEADTECH_TS_AUTHKEY:?LEADTECH_TS_AUTHKEY manquant — voir .env.example}
TS_HOSTNAME: leadtech-mcp
TS_STATE_DIR: /var/lib/tailscale
TS_SERVE_CONFIG: /config/serve.json
TS_USERSPACE: "false"
TS_EXTRA_ARGS: --accept-dns=false
volumes:
- /srv/docker-data/leadtech-mcp/tailscale/state:/var/lib/tailscale
- ./tailscale/serve.json:/config/serve.json:ro
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- SYS_MODULE
networks:
- internal
labels:
- "homepage.group=Infra"
- "homepage.name=Lead_tech MCP"
- "homepage.href=https://leadtech-mcp.wyvern-snapper.ts.net/mcp"
networks:
internal:
name: leadtech-mcp-internal
driver: bridge
@@ -1,8 +1,13 @@
# leadtech-bmad-mcp — Design : MCP central sur NUC via Tailscale # leadtech-bmad-mcp — Design : MCP central sur NUC via Tailscale
> **Statut : PISTE / chantier non démarré** (2026-06-25). > **Statut : IMPLÉMENTÉ — déploiement à finaliser** (mise à jour 2026-06-25).
> Ce document capture un design abouti mais non implémenté. Ce n'est PAS une décision figée > Le transport HTTP, l'image Docker, le compose et le sidecar Tailscale sont en place et
> (pas dans `40_decisions_et_archi.md` tant que le chantier n'est pas lancé). À reprendre tel quel. > testés (handshake MCP `streamable-http` OK, 79 tests verts). Reste à fournir l'authkey
> Tailscale et lancer `docker compose up` sur le host. Voir "État d'implémentation" plus bas.
>
> Host réel : `docker-dev.wyvern-snapper.ts.net` (le "NUC" du design ; tailnet `wyvern-snapper`).
> Hostname tailnet dédié du MCP : **`leadtech-mcp`** (sidecar) →
> URL client : **`https://leadtech-mcp.wyvern-snapper.ts.net/mcp`**.
## Besoin ## Besoin
@@ -53,15 +58,34 @@ Briques déjà en place :
Brique manquante : le **déclencheur de seuil** (compter les entrées, lancer le triage au-delà de N). Brique manquante : le **déclencheur de seuil** (compter les entrées, lancer le triage au-delà de N).
## Reste à faire (quand le chantier démarre) ## État d'implémentation
- [ ] POC : passer le serveur en transport HTTP (`mcp.run(transport="streamable-http", host=..., port=...)`) et tester en local Fait (code + infra dans `mcp/leadtech_bmad_mcp/`) :
- [ ] Dockerfile + compose (bind `/srv/helpers/_Assistant_Lead_Tech`, env `LEADTECH_ROOT`, `LEADTECH_MCP_ALLOW_WRITE`)
- [ ] Bind sur l'interface Tailscale du NUC ; vérifier l'accès depuis le Mac via le tailnet - [x] Transport HTTP : `server.main()` lit `LEADTECH_MCP_TRANSPORT` (`stdio` par défaut → aucune
- [ ] Reconfigurer les clients : `"leadtech-bmad": { "type": "http", "url": "http://nuc.<tailnet>.ts.net:<port>/mcp" }` régression locale ; `streamable-http` pour le central). Host/port via `LEADTECH_MCP_HOST`/`_PORT`.
- [ ] Déclencheur de seuil pour le triage de capitalisation - [x] `Dockerfile` (`python:3.11-slim`, install `-e .`, build index au démarrage, lance le serveur HTTP).
- [ ] Décider du flux Git de `95_a_capitaliser.md` (commit/push manuel au NUC après validation — option retenue par défaut) - [x] `docker-compose.yml` : service `mcp` (réseau interne `leadtech-mcp-internal`, **aucun port publié**)
- [ ] S'assurer que les projets cibles de `route_to_project_memory` sont clonés sur le NUC (sinon tool sans effet pour eux) + sidecar `tailscale` (`tailscale serve` TLS MagicDNS). `user: "1000:1000"` pour écrire l'index
et `95_a_capitaliser.md` dans le bind-mont sans casser les permissions du repo.
- [x] `tailscale/serve.json` (proxy `/``http://mcp:8080`, pas de Funnel).
- [x] `.env.example` (authkey) + `.env` gitignoré.
- [x] `LEADTECH_MCP_ALLOW_WRITE=1` câblé sur le service central.
- [x] Validé : handshake MCP `initialize` sur `/mcp` → HTTP 200 + `serverInfo` ; 79 tests verts dans l'image.
Reste à faire pour mettre en service :
- [ ] Générer une authkey Tailscale (Reusable, NON-Ephemeral), la mettre dans `.env` (`LEADTECH_TS_AUTHKEY`).
- [ ] `docker compose up -d --build` sur le host, approuver le nœud `leadtech-mcp` si machine-approval,
puis **disable key expiry** sur ce nœud.
- [ ] Vérifier depuis le Mac : `curl https://leadtech-mcp.wyvern-snapper.ts.net/mcp` (handshake).
- [ ] Reconfigurer les clients (cf. `mcp.config.http.example.json`) puis relancer la session Claude Code.
Pistes non bloquantes (hors périmètre de ce déploiement) :
- [ ] Déclencheur de seuil pour le triage de capitalisation (brique manquante identifiée plus haut).
- [ ] Flux Git de `95_a_capitaliser.md` (commit/push manuel au host après validation — option retenue par défaut).
- [ ] Vérifier que les projets cibles de `route_to_project_memory` sont présents (ici `/srv/projects/*` ✅, bind-monté).
## Points d'attention ## Points d'attention
@@ -0,0 +1,9 @@
{
"//": "Config client pour le MCP central (NUC + sidecar Tailscale). Remplace la variante stdio (mcp.config.example.json). Nécessite que la machine cliente soit sur le tailnet wyvern-snapper. Relancer la session Claude Code après modif.",
"mcpServers": {
"leadtech-bmad": {
"type": "http",
"url": "https://leadtech-mcp.wyvern-snapper.ts.net/mcp"
}
}
}
@@ -18,7 +18,40 @@ Domain = Literal["backend", "frontend", "ux", "n8n", "product", "workflow"]
TaskType = Literal["analysis", "implementation", "review", "debug", "architecture"] TaskType = Literal["analysis", "implementation", "review", "debug", "architecture"]
AgentRole = Literal["analyst", "builder", "reviewer", "curator"] AgentRole = Literal["analyst", "builder", "reviewer", "curator"]
mcp = FastMCP(name="leadtech-bmad-mcp")
def _build_transport_security():
"""Configure les hôtes/origines autorisés pour le transport HTTP.
En streamable-http bindé sur 0.0.0.0, la protection anti-DNS-rebinding de MCP
rejette par défaut tout `Host` non-localhost (HTTP 421 "Invalid Host header").
Derrière le sidecar Tailscale, le client tape `leadtech-mcp.<tailnet>.ts.net`,
qu'il faut donc whitelister via `LEADTECH_MCP_ALLOWED_HOSTS` (CSV).
Retourne None en mode stdio/local → comportement par défaut inchangé.
"""
allowed = os.getenv("LEADTECH_MCP_ALLOWED_HOSTS", "").strip()
if not allowed:
return None
try:
from mcp.server.transport_security import TransportSecuritySettings
except ImportError:
return None
hosts: list[str] = []
origins: list[str] = []
for raw in allowed.split(","):
h = raw.strip()
if not h:
continue
hosts.append(h)
origins.append(f"https://{h}")
origins.append(f"http://{h}")
return TransportSecuritySettings(allowed_hosts=hosts, allowed_origins=origins)
_transport_security = _build_transport_security()
if _transport_security is not None:
mcp = FastMCP(name="leadtech-bmad-mcp", transport_security=_transport_security)
else:
mcp = FastMCP(name="leadtech-bmad-mcp")
def _base_output() -> dict: def _base_output() -> dict:
payload = empty_gate_output() payload = empty_gate_output()
payload["gates"] = get_gate_labels() payload["gates"] = get_gate_labels()
@@ -292,7 +325,27 @@ def route_to_project_memory(project_name: str, section: Literal["Lecons apprises
def main() -> None: def main() -> None:
mcp.run() """Point d'entrée du serveur.
Transport piloté par l'environnement pour ne rien casser en local :
- `LEADTECH_MCP_TRANSPORT` non défini ou `stdio` (défaut) → stdio, comme avant.
- `streamable-http` → serveur HTTP réseau (déploiement central NUC/Tailscale).
`LEADTECH_MCP_HOST` (défaut 0.0.0.0) et `LEADTECH_MCP_PORT` (défaut 8080)
pilotent le bind. En sidecar Tailscale, on bind 0.0.0.0 dans le réseau
Docker interne — l'exposition au tailnet est faite par le sidecar.
"""
transport = os.getenv("LEADTECH_MCP_TRANSPORT", "stdio").strip().lower()
if transport in {"http", "streamable-http", "streamable_http"}:
mcp.settings.host = os.getenv("LEADTECH_MCP_HOST", "0.0.0.0")
mcp.settings.port = int(os.getenv("LEADTECH_MCP_PORT", "8080"))
mcp.run(transport="streamable-http")
elif transport == "sse":
mcp.settings.host = os.getenv("LEADTECH_MCP_HOST", "0.0.0.0")
mcp.settings.port = int(os.getenv("LEADTECH_MCP_PORT", "8080"))
mcp.run(transport="sse")
else:
mcp.run()
if __name__ == "__main__": if __name__ == "__main__":
@@ -0,0 +1,15 @@
{
"TCP": {
"443": { "HTTPS": true }
},
"Web": {
"leadtech-mcp.wyvern-snapper.ts.net:443": {
"Handlers": {
"/": { "Proxy": "http://mcp:8080" }
}
}
},
"AllowFunnel": {
"leadtech-mcp.wyvern-snapper.ts.net:443": false
}
}