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:
@@ -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
|
||||
|
||||
> **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.<tailnet>.ts.net:<port>/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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
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:
|
||||
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__":
|
||||
|
||||
@@ -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