mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 01:53:40 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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,6 +325,26 @@ def route_to_project_memory(project_name: str, section: Literal["Lecons apprises
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""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()
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user