diff --git a/.gitignore b/.gitignore index e0fce64..5261fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ dist/ # Leadtech MCP — index local généré (regenerable via leadtech-bmad-build-index) .leadtech_mcp_index.json + +# Leadtech MCP — secrets du déploiement central (le .env.example reste versionné) +.env diff --git a/mcp/leadtech_bmad_mcp/.env.example b/mcp/leadtech_bmad_mcp/.env.example new file mode 100644 index 0000000..a325970 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/.env.example @@ -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 diff --git a/mcp/leadtech_bmad_mcp/Dockerfile b/mcp/leadtech_bmad_mcp/Dockerfile new file mode 100644 index 0000000..9041131 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/Dockerfile @@ -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"] diff --git a/mcp/leadtech_bmad_mcp/docker-compose.yml b/mcp/leadtech_bmad_mcp/docker-compose.yml new file mode 100644 index 0000000..909266d --- /dev/null +++ b/mcp/leadtech_bmad_mcp/docker-compose.yml @@ -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 diff --git a/mcp/leadtech_bmad_mcp/docs/design_nuc_tailscale.md b/mcp/leadtech_bmad_mcp/docs/design_nuc_tailscale.md index f4e7a91..ffd0d0c 100644 --- a/mcp/leadtech_bmad_mcp/docs/design_nuc_tailscale.md +++ b/mcp/leadtech_bmad_mcp/docs/design_nuc_tailscale.md @@ -1,8 +1,13 @@ # leadtech-bmad-mcp — Design : MCP central sur NUC via Tailscale -> **Statut : PISTE / chantier non démarré** (2026-06-25). -> Ce document capture un design abouti mais non implémenté. Ce n'est PAS une décision figée -> (pas dans `40_decisions_et_archi.md` tant que le chantier n'est pas lancé). À reprendre tel quel. +> **Statut : IMPLÉMENTÉ — déploiement à finaliser** (mise à jour 2026-06-25). +> Le transport HTTP, l'image Docker, le compose et le sidecar Tailscale sont en place et +> 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 @@ -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). -## 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 -- [ ] 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 -- [ ] Reconfigurer les clients : `"leadtech-bmad": { "type": "http", "url": "http://nuc..ts.net:/mcp" }` -- [ ] Déclencheur de seuil pour le triage de capitalisation -- [ ] Décider du flux Git de `95_a_capitaliser.md` (commit/push manuel au NUC après validation — option retenue par défaut) -- [ ] S'assurer que les projets cibles de `route_to_project_memory` sont clonés sur le NUC (sinon tool sans effet pour eux) +Fait (code + infra dans `mcp/leadtech_bmad_mcp/`) : + +- [x] Transport HTTP : `server.main()` lit `LEADTECH_MCP_TRANSPORT` (`stdio` par défaut → aucune + régression locale ; `streamable-http` pour le central). Host/port via `LEADTECH_MCP_HOST`/`_PORT`. +- [x] `Dockerfile` (`python:3.11-slim`, install `-e .`, build index au démarrage, lance le serveur HTTP). +- [x] `docker-compose.yml` : service `mcp` (réseau interne `leadtech-mcp-internal`, **aucun port publié**) + + 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 diff --git a/mcp/leadtech_bmad_mcp/mcp.config.http.example.json b/mcp/leadtech_bmad_mcp/mcp.config.http.example.json new file mode 100644 index 0000000..110a114 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/mcp.config.http.example.json @@ -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" + } + } +} diff --git a/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py index 34433dc..ba08655 100644 --- a/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py +++ b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py @@ -18,7 +18,40 @@ Domain = Literal["backend", "frontend", "ux", "n8n", "product", "workflow"] TaskType = Literal["analysis", "implementation", "review", "debug", "architecture"] 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..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: payload = empty_gate_output() payload["gates"] = get_gate_labels() @@ -292,7 +325,27 @@ def route_to_project_memory(project_name: str, section: Literal["Lecons apprises 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__": diff --git a/mcp/leadtech_bmad_mcp/tailscale/serve.json b/mcp/leadtech_bmad_mcp/tailscale/serve.json new file mode 100644 index 0000000..7a91156 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/tailscale/serve.json @@ -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 + } +}