- brain-engine: server, embed, search, RAG, MCP, start.sh (standalone) - brain-ui: source React complète, build.sh, DocsView avec tier colors - docs: 14 pages guides humains (getting-started, architecture, sessions, workflows, agents, vues tier) - brain-compose.yml v0.9.0: tier featured ajouté, sessions/agents par tier, coach_level, API key schema - DISTRIBUTION_CHECKLIST v1.2: brain-engine + brain-ui + docs dans la checklist
413 lines
16 KiB
Python
413 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
brain-engine/mcp_server.py — BE-4 MCP Server
|
|
Expose le brain comme source de contexte native pour Claude.
|
|
|
|
Transport : StreamableHTTP (MCP 1.x)
|
|
Port : 7701 (défaut) — distinct du BaaS HTTP (7700)
|
|
Auth : BRAIN_TOKEN_MCP dans MYSECRETS → passé via header x-api-key
|
|
|
|
Outils exposés :
|
|
brain_search(query, top) → recherche sémantique (zones public + work)
|
|
brain_boot() → contexte de boot (3 queries ciblées)
|
|
brain_workflows() → workflows actifs (claims BSI ouverts)
|
|
brain_agents(name) → liste des agents ou contenu d'un agent
|
|
brain_decisions(last) → dernières décisions architecturales (ADRs)
|
|
brain_focus() → focus actuel du brain (direction + projets + blockers)
|
|
brain_write(path, content)→ écrire un fichier dans le brain via PUT /brain/{path}
|
|
|
|
Usage :
|
|
python3 brain-engine/mcp_server.py → port 7701 (défaut)
|
|
BRAIN_MCP_PORT=8000 python3 brain-engine/mcp_server.py
|
|
|
|
Connexion Claude Code :
|
|
claude mcp add brain --transport http http://localhost:7701/mcp/
|
|
|
|
Auth dans Claude Code :
|
|
Settings → MCP → brain → Headers → x-api-key: <BRAIN_TOKEN_MCP>
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from rag import run_boot_queries, run_single_query, format_compact, format_full
|
|
|
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
|
|
|
BRAIN_MCP_PORT = int(os.getenv('BRAIN_MCP_PORT', 7701))
|
|
BRAIN_TOKEN_MCP = os.getenv('BRAIN_TOKEN_MCP') or os.getenv('BRAIN_TOKEN')
|
|
|
|
# Scopes autorisés pour le token MCP
|
|
MCP_SCOPES = ['public', 'work']
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
log = logging.getLogger('brain-mcp')
|
|
|
|
# ── MCP Server ─────────────────────────────────────────────────────────────────
|
|
|
|
mcp = FastMCP(
|
|
name='brain',
|
|
instructions=(
|
|
'Brain-as-a-Service — mémoire sémantique du brain. '
|
|
'Utilise brain_search pour trouver du contexte précis sur un sujet. '
|
|
'Utilise brain_boot au démarrage d\'une session pour charger le contexte actif. '
|
|
'Les résultats sont des chunks de fichiers markdown classés par pertinence. '
|
|
'Zones accessibles : focus, todos, projets, agents, infrastructure.'
|
|
),
|
|
)
|
|
|
|
|
|
# ── Auth middleware ─────────────────────────────────────────────────────────────
|
|
|
|
class BrainAuthMiddleware:
|
|
"""
|
|
Wrapper ASGI — vérifie x-api-key avant chaque requête MCP.
|
|
Note : les dunders Python (__call__) sont résolus sur la classe, pas l'instance.
|
|
Un vrai wrapper ASGI est requis (monkey-patch d'instance ne fonctionne pas).
|
|
"""
|
|
def __init__(self, app, token: str | None):
|
|
self._app = app
|
|
self._token = token
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope['type'] == 'http' and self._token:
|
|
headers = dict(scope.get('headers', []))
|
|
api_key = headers.get(b'x-api-key', b'').decode()
|
|
if api_key != self._token:
|
|
async def _send_401():
|
|
await send({'type': 'http.response.start', 'status': 401,
|
|
'headers': [(b'content-type', b'application/json')]})
|
|
await send({'type': 'http.response.body',
|
|
'body': b'{"error":"Unauthorized"}', 'more_body': False})
|
|
await _send_401()
|
|
return
|
|
await self._app(scope, receive, send)
|
|
|
|
|
|
mcp_app = BrainAuthMiddleware(mcp.streamable_http_app(), BRAIN_TOKEN_MCP)
|
|
|
|
|
|
# ── Outils MCP ─────────────────────────────────────────────────────────────────
|
|
|
|
@mcp.tool()
|
|
def brain_search(query: str, top: int = 5, full: bool = False) -> str:
|
|
"""
|
|
Recherche sémantique dans le brain.
|
|
|
|
Args:
|
|
query : Question en langage naturel (ex: "comment fonctionne le BSI v2 ?")
|
|
top : Nombre de résultats (défaut: 5, max recommandé: 10)
|
|
full : True = chunks complets, False = extraits 120 chars (défaut)
|
|
|
|
Returns:
|
|
Bloc markdown avec les chunks les plus pertinents, triés par score.
|
|
Chaque résultat indique le filepath source et un extrait du contenu.
|
|
"""
|
|
log.info('brain_search query=%r top=%d full=%s', query, top, full)
|
|
results = run_single_query(query, top_k=top, allowed_scopes=MCP_SCOPES)
|
|
if not results:
|
|
return f'Aucun résultat pour : {query!r}'
|
|
label = f'brain_search — {query}'
|
|
return format_full(results, label=label) if full else format_compact(results, label=label)
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_state() -> str:
|
|
"""
|
|
Environnement fondamental du brain — dérivé en temps réel, jamais stocké.
|
|
|
|
Retourne les services actifs (pm2), la version brain (git), et les ports
|
|
configurés. Layer 2 uniquement (localhost).
|
|
|
|
À appeler en début de session pour connaître l'état de l'infrastructure
|
|
sans avoir à demander "quel port ? quel service tourne ?".
|
|
|
|
Returns:
|
|
Bloc markdown structuré avec hostname, version, pm2 status, ports.
|
|
"Indisponible" si brain-engine hors ligne.
|
|
"""
|
|
import json
|
|
import urllib.request
|
|
log.info('brain_state')
|
|
try:
|
|
with urllib.request.urlopen('http://127.0.0.1:7700/state', timeout=3) as resp:
|
|
data = json.loads(resp.read())
|
|
lines = [f'## Environnement fondamental\n']
|
|
lines.append(f"**Machine** : {data.get('hostname', '?')}")
|
|
lines.append(f"**Brain** : {data.get('brain_version', '?')}\n")
|
|
pm2 = data.get('pm2', [])
|
|
if pm2:
|
|
lines.append('**Services (pm2)**')
|
|
lines.append('| Nom | Status | Restarts |')
|
|
lines.append('|-----|--------|---------|')
|
|
for p in pm2:
|
|
icon = '🟢' if p.get('status') == 'online' else '🔴'
|
|
lines.append(f"| {p['name']} | {icon} {p.get('status','?')} | {p.get('restarts',0)} |")
|
|
ports = data.get('ports', {})
|
|
if ports:
|
|
lines.append(f"\n**Ports** : engine={ports.get('brain_engine','?')} · mcp={ports.get('brain_mcp','?')} · key={ports.get('brain_key','?')}")
|
|
return '\n'.join(lines)
|
|
except Exception as exc:
|
|
log.warning('brain_state failed: %s', exc)
|
|
return f'Environnement indisponible : {exc}'
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_boot() -> str:
|
|
"""
|
|
Charge le contexte de boot du brain.
|
|
|
|
Séquence :
|
|
1. brain/now.md — slot garanti (push de la session précédente)
|
|
2. brain_state() — environnement fondamental dérivé (pm2, ports)
|
|
3. 3 queries RAG ciblées (décisions récentes, todos prioritaires, sprint actif)
|
|
|
|
À appeler en début de session pour enrichir le contexte sans saturer le
|
|
context window. Exit silencieux si Ollama indisponible.
|
|
|
|
Returns:
|
|
Bloc markdown additif avec contexte de boot complet.
|
|
"""
|
|
log.info('brain_boot')
|
|
sections = []
|
|
|
|
# 1. Slot garanti — brain/now.md
|
|
now_path = Path(__file__).parent.parent / 'brain' / 'now.md'
|
|
if now_path.exists():
|
|
try:
|
|
content = now_path.read_text(encoding='utf-8').strip()
|
|
if content:
|
|
sections.append(content)
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Environnement dérivé
|
|
env = brain_state()
|
|
if env and 'Indisponible' not in env:
|
|
sections.append(env)
|
|
|
|
# 3. RAG queries
|
|
results = run_boot_queries(allowed_scopes=MCP_SCOPES)
|
|
if results:
|
|
sections.append(format_compact(results, label='brain_boot'))
|
|
|
|
return '\n\n---\n\n'.join(sections) if sections else ''
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_workflows() -> str:
|
|
"""
|
|
Retourne les workflows actifs du brain (claims BSI ouverts).
|
|
|
|
Returns:
|
|
Bloc markdown avec les workflows en cours : nom, projet, étapes, statuts.
|
|
Utile en début de session pour connaître l'état des sprints actifs.
|
|
"""
|
|
import json
|
|
import urllib.request
|
|
log.info('brain_workflows')
|
|
try:
|
|
url = f'http://127.0.0.1:7700/workflows'
|
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
|
data = json.loads(resp.read())
|
|
workflows = data.get('workflows', [])
|
|
if not workflows:
|
|
return 'Aucun workflow actif.'
|
|
lines = ['## Workflows actifs\n']
|
|
for wf in workflows:
|
|
lines.append(f"### {wf.get('name', wf.get('id', '?'))} — {wf.get('project', '')}")
|
|
for step in wf.get('steps', []):
|
|
status = step.get('status', '?')
|
|
icon = {'done': '✅', 'in-progress': '🔄', 'pending': '⬜',
|
|
'gate': '🔶', 'blocked': '🔴', 'fail': '❌'}.get(status, '•')
|
|
gate = ' [GATE]' if step.get('isGate') else ''
|
|
lines.append(f" {icon} {step.get('label', step.get('id', '?'))}{gate}")
|
|
lines.append('')
|
|
return '\n'.join(lines)
|
|
except Exception as exc:
|
|
log.warning('brain_workflows failed: %s', exc)
|
|
return f'Workflows indisponibles : {exc}'
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_agents(name: str = '') -> str:
|
|
"""
|
|
Retourne les agents disponibles dans le brain.
|
|
|
|
Args:
|
|
name : Nom de l'agent (sans extension .md). Si vide, retourne la liste
|
|
complète. Exemple : "debug", "vps", "code-review".
|
|
|
|
Returns:
|
|
Liste des agents en tableau markdown (nom, status, context_tier, description)
|
|
ou contenu brut du fichier agents/{name}.md si name fourni.
|
|
Fallback filesystem si brain-engine indisponible.
|
|
"""
|
|
import json
|
|
import urllib.request
|
|
BRAIN_ROOT = Path(__file__).parent.parent
|
|
log.info('brain_agents name=%r', name)
|
|
|
|
if name:
|
|
agent_path = BRAIN_ROOT / 'agents' / f'{name}.md'
|
|
if not agent_path.exists():
|
|
return f'Agent introuvable : agents/{name}.md'
|
|
return agent_path.read_text(encoding='utf-8')
|
|
|
|
# Liste via brain-engine
|
|
try:
|
|
with urllib.request.urlopen('http://127.0.0.1:7700/agents', timeout=3) as resp:
|
|
data = json.loads(resp.read())
|
|
agents = data.get('agents', data) if isinstance(data, dict) else data
|
|
if not agents:
|
|
return 'Aucun agent trouvé.'
|
|
lines = ['## Agents disponibles\n', '| Nom | Status | Tier | Description |',
|
|
'|-----|--------|------|-------------|']
|
|
for ag in agents:
|
|
nom = ag.get('name', ag.get('id', '?'))
|
|
stat = ag.get('status', '—')
|
|
tier = ag.get('context_tier', '—')
|
|
desc = (ag.get('boot_summary') or ag.get('description') or '')[:80]
|
|
lines.append(f'| {nom} | {stat} | {tier} | {desc} |')
|
|
return '\n'.join(lines)
|
|
except Exception as exc:
|
|
log.warning('brain_agents HTTP failed, fallback filesystem: %s', exc)
|
|
|
|
# Fallback filesystem
|
|
agents_dir = BRAIN_ROOT / 'agents'
|
|
if not agents_dir.exists():
|
|
return 'Répertoire agents/ introuvable.'
|
|
files = sorted(agents_dir.glob('*.md'))
|
|
if not files:
|
|
return 'Aucun agent trouvé.'
|
|
lines = ['## Agents disponibles (filesystem)\n', '| Nom |', '|-----|']
|
|
for f in files:
|
|
lines.append(f'| {f.stem} |')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_decisions(last: int = 5) -> str:
|
|
"""
|
|
Retourne les dernières décisions architecturales (ADRs).
|
|
|
|
Lit les fichiers profil/decisions/*.md, triés par nom décroissant
|
|
(numérotation → plus récent en premier).
|
|
|
|
Args:
|
|
last : Nombre d'ADRs à retourner (défaut: 5).
|
|
|
|
Returns:
|
|
Bloc markdown avec numéro, titre, statut, date et résumé (150 chars)
|
|
de chaque ADR. "Aucune décision trouvée" si le répertoire est absent.
|
|
"""
|
|
BRAIN_ROOT = Path(__file__).parent.parent
|
|
log.info('brain_decisions last=%d', last)
|
|
decisions_dir = BRAIN_ROOT / 'profil' / 'decisions'
|
|
if not decisions_dir.exists():
|
|
return 'Aucune décision trouvée.'
|
|
files = sorted(decisions_dir.glob('*.md'), reverse=True)[:last]
|
|
if not files:
|
|
return 'Aucune décision trouvée.'
|
|
lines = ['## Décisions architecturales récentes\n']
|
|
for f in files:
|
|
body = f.read_text(encoding='utf-8')
|
|
# Extraire titre (première ligne # ...)
|
|
titre = next((l.lstrip('# ').strip() for l in body.splitlines() if l.startswith('#')), f.stem)
|
|
# Extraire statut et date depuis les premières lignes (format ADR standard)
|
|
statut = '—'
|
|
date = '—'
|
|
for line in body.splitlines():
|
|
ll = line.lower()
|
|
if ll.startswith('statut') or ll.startswith('status') or ll.startswith('- statut'):
|
|
statut = line.split(':', 1)[-1].strip()
|
|
if ll.startswith('date') or ll.startswith('- date'):
|
|
date = line.split(':', 1)[-1].strip()
|
|
# Résumé : premier paragraphe non-titre non-vide de moins de 150 chars
|
|
resume = ''
|
|
for line in body.splitlines():
|
|
if line.startswith('#') or not line.strip():
|
|
continue
|
|
resume = line.strip()[:150]
|
|
break
|
|
lines.append(f'### {f.stem} — {titre}')
|
|
lines.append(f'**Statut** : {statut} | **Date** : {date}')
|
|
lines.append(f'{resume}')
|
|
lines.append('')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_focus() -> str:
|
|
"""
|
|
Retourne le focus actuel du brain.
|
|
|
|
Lit BRAIN_ROOT/focus.md et retourne le contenu brut.
|
|
Utile pour connaître la direction active, les projets en cours et les blockers.
|
|
|
|
Returns:
|
|
Contenu complet de focus.md ou "focus.md non trouvé".
|
|
"""
|
|
BRAIN_ROOT = Path(__file__).parent.parent
|
|
log.info('brain_focus')
|
|
focus_path = BRAIN_ROOT / 'focus.md'
|
|
if not focus_path.exists():
|
|
return 'focus.md non trouvé.'
|
|
return focus_path.read_text(encoding='utf-8')
|
|
|
|
|
|
@mcp.tool()
|
|
def brain_write(path: str, content: str) -> str:
|
|
"""
|
|
Écrit un fichier dans le brain via PUT /brain/{path}.
|
|
|
|
Réservé aux sessions owner. Permet de mettre à jour n'importe quel fichier
|
|
du brain depuis une session Claude avec MCP actif.
|
|
|
|
Args:
|
|
path : Chemin relatif dans le brain (ex: "focus.md", "todos/sprint.md").
|
|
content : Contenu complet du fichier à écrire.
|
|
|
|
Returns:
|
|
JSON {"ok": true, "path": path} en cas de succès,
|
|
message d'erreur sinon. 403 → "Requiert tier owner".
|
|
"""
|
|
import json
|
|
import urllib.request
|
|
log.info('brain_write path=%r len=%d', path, len(content))
|
|
url = f'http://127.0.0.1:7700/brain/{path}'
|
|
payload = json.dumps({'content': content}).encode('utf-8')
|
|
req = urllib.request.Request(
|
|
url, data=payload, method='PUT',
|
|
headers={'Content-Type': 'application/json'},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
body = resp.read()
|
|
return json.dumps({'ok': True, 'path': path})
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 403:
|
|
return 'Requiert tier owner — écriture refusée.'
|
|
return f'Erreur {exc.code} : {exc.reason}'
|
|
except Exception as exc:
|
|
log.warning('brain_write failed: %s', exc)
|
|
return f'brain_write indisponible : {exc}'
|
|
|
|
|
|
# ── Entrypoint ─────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == '__main__':
|
|
import uvicorn
|
|
auth_status = 'token actif' if BRAIN_TOKEN_MCP else 'auth désactivée (dev)'
|
|
log.info('Brain MCP BE-4 — port %d — %s — scopes: %s',
|
|
BRAIN_MCP_PORT, auth_status, MCP_SCOPES)
|
|
uvicorn.run(mcp_app, host='0.0.0.0', port=BRAIN_MCP_PORT,
|
|
forwarded_allow_ips='*', proxy_headers=True)
|