feat: serveur MCP Sliding avec OAuth 2.1 partage (C8)

This commit is contained in:
2026-07-12 08:04:41 +02:00
parent d3e17b8ebb
commit 6e8167a91c
6 changed files with 866 additions and 0 deletions
+4
View File
@@ -13,3 +13,7 @@ projets/*/inputs/
*.bak_* *.bak_*
assets/ assets/
projets/ projets/
oauth_state.json
mcp.pid
mcp.log
watchdog.log
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mcp_oauth.py — OAuth 2.1 partagé pour les serveurs MCP · Chantier C8
===================================================================
Extrait fidèle du bloc OAuth de mcp_server.py (Context Continuity),
rendu réutilisable : chaque serveur MCP l'instancie avec sa propre URL
de base et son propre fichier d'état. Aucune logique métier ici.
Modèle : OAuth 2.1 mono-utilisateur, protégé par un « mot de sécurité »
(SECURITY_WORD). Dynamic Client Registration (RFC 7591), PKCE S256,
authorization_code + refresh_token. Tokens émis préfixés « at_ ».
État persistant dans un JSON (clients / auth_codes / tokens).
Compatible Python 3.9, Starlette pur.
Usage dans un serveur MCP :
from mcp_oauth import OAuthProvider
oauth = OAuthProvider(
base_url=os.getenv("OAUTH_BASE_URL", "https://mcp-x.exemple.fr"),
state_file=os.path.expanduser("~/App/X/oauth_state.json"),
security_word=os.getenv("SECURITY_WORD", ""),
realm="X MCP")
# routes : oauth.routes() → à concaténer aux routes du serveur
# auth : oauth.check_token(token) → True si token at_ valide
"""
import base64
import hashlib
import json
import os
import secrets
import time
from starlette.responses import (HTMLResponse, JSONResponse,
RedirectResponse)
from starlette.routing import Route
AUTHORIZE_PAGE = """<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8"><title>Autorisation {realm}</title>
<style>body{{font-family:sans-serif;max-width:400px;margin:80px auto;padding:20px}}
input{{width:100%;padding:10px;margin:10px 0;box-sizing:border-box}}
button{{padding:10px 20px;background:#061033;color:#fff;border:none;cursor:pointer}}
.err{{color:#c00}}</style></head>
<body><h2>Autorisation {realm}</h2>
<p>Saisissez le mot de securite pour autoriser l acces.</p>
{error}
<form method="POST" action="/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
<input type="password" name="security_word" placeholder="Mot de securite" autofocus>
<button type="submit">Autoriser</button>
</form></body></html>"""
def _verify_pkce(code_verifier, code_challenge):
"""PKCE S256 : base64url(sha256(verifier)) == challenge."""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return computed == code_challenge
class OAuthProvider:
def __init__(self, base_url, state_file, security_word, realm="MCP"):
self.base_url = base_url.rstrip("/")
self.state_file = state_file
self.security_word = security_word
self.realm = realm
self._ensure_state()
# ── état ────────────────────────────────────────────────────────────
def _ensure_state(self):
if not os.path.exists(self.state_file):
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
self._save({"clients": {}, "auth_codes": {}, "tokens": {}})
def _load(self):
with open(self.state_file) as f:
state = json.load(f)
for k in ("clients", "auth_codes", "tokens"):
state.setdefault(k, {})
return state
def _save(self, state):
with open(self.state_file, "w") as f:
json.dump(state, f, indent=2)
# ── vérification (appelée par check_auth du serveur) ────────────────
def check_token(self, token):
"""True si le token est un access_token OAuth émis et connu."""
if not token or not token.startswith("at_"):
return False
return bool(self._load()["tokens"].get(token))
# ── endpoints ───────────────────────────────────────────────────────
async def well_known_protected_resource(self, request):
return JSONResponse({
"resource": self.base_url + "/mcp",
"authorization_servers": [self.base_url],
})
async def well_known_authorization_server(self, request):
return JSONResponse({
"issuer": self.base_url,
"authorization_endpoint": self.base_url + "/authorize",
"token_endpoint": self.base_url + "/token",
"registration_endpoint": self.base_url + "/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code",
"refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
})
async def register(self, request):
"""Dynamic Client Registration (RFC 7591)."""
try:
body = await request.json()
except Exception:
body = {}
client_id = "client_" + secrets.token_urlsafe(16)
state = self._load()
state["clients"][client_id] = {
"client_id": client_id,
"redirect_uris": body.get("redirect_uris", []),
"client_name": body.get("client_name", "unknown"),
"created_at": int(time.time()),
}
self._save(state)
return JSONResponse({
"client_id": client_id,
"redirect_uris": body.get("redirect_uris", []),
"client_name": body.get("client_name", "unknown"),
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
}, status_code=201)
async def authorize_get(self, request):
q = request.query_params
page = AUTHORIZE_PAGE.format(
realm=self.realm, error="",
client_id=q.get("client_id", ""),
redirect_uri=q.get("redirect_uri", ""),
state=q.get("state", ""),
code_challenge=q.get("code_challenge", ""),
code_challenge_method=q.get("code_challenge_method", "S256"),
)
return HTMLResponse(page)
async def authorize_post(self, request):
form = await request.form()
word = form.get("security_word", "")
client_id = form.get("client_id", "")
redirect_uri = form.get("redirect_uri", "")
state_param = form.get("state", "")
code_challenge = form.get("code_challenge", "")
code_challenge_method = form.get("code_challenge_method", "S256")
if word != self.security_word:
page = AUTHORIZE_PAGE.format(
realm=self.realm,
error='<p class="err">Mot de securite incorrect.</p>',
client_id=client_id, redirect_uri=redirect_uri,
state=state_param, code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
return HTMLResponse(page, status_code=401)
oauth = self._load()
if client_id not in oauth["clients"]:
return JSONResponse({"error": "client inconnu"},
status_code=400)
code = "code_" + secrets.token_urlsafe(24)
oauth["auth_codes"][code] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"created_at": int(time.time()),
"used": False,
}
self._save(oauth)
sep = "&" if "?" in redirect_uri else "?"
location = redirect_uri + sep + "code=" + code
if state_param:
location += "&state=" + state_param
return RedirectResponse(location, status_code=302)
async def token(self, request):
form = await request.form()
grant_type = form.get("grant_type", "")
oauth = self._load()
if grant_type == "authorization_code":
code = form.get("code", "")
code_verifier = form.get("code_verifier", "")
entry = oauth["auth_codes"].get(code)
if not entry:
return JSONResponse({"error": "invalid_grant"},
status_code=400)
if entry.get("used"):
return JSONResponse(
{"error": "invalid_grant",
"error_description": "code deja utilise"},
status_code=400)
challenge = entry.get("code_challenge", "")
if challenge:
if not code_verifier or not _verify_pkce(code_verifier,
challenge):
return JSONResponse(
{"error": "invalid_grant",
"error_description": "PKCE echec"},
status_code=400)
access_token = "at_" + secrets.token_urlsafe(32)
refresh_token = "rt_" + secrets.token_urlsafe(32)
oauth["tokens"][access_token] = {
"client_id": entry["client_id"],
"refresh_token": refresh_token,
"created_at": int(time.time()),
"type": "access",
}
oauth["auth_codes"][code]["used"] = True
self._save(oauth)
return JSONResponse({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 31536000,
"refresh_token": refresh_token,
})
elif grant_type == "refresh_token":
rt = form.get("refresh_token", "")
for at, meta in list(oauth["tokens"].items()):
if meta.get("refresh_token") == rt:
new_at = "at_" + secrets.token_urlsafe(32)
oauth["tokens"][new_at] = {
"client_id": meta["client_id"],
"refresh_token": rt,
"created_at": int(time.time()),
"type": "access",
}
self._save(oauth)
return JSONResponse({
"access_token": new_at,
"token_type": "Bearer",
"expires_in": 31536000,
"refresh_token": rt,
})
return JSONResponse({"error": "invalid_grant"},
status_code=400)
return JSONResponse({"error": "unsupported_grant_type"},
status_code=400)
def routes(self):
"""Routes OAuth à concaténer à celles du serveur MCP."""
return [
Route("/.well-known/oauth-protected-resource",
self.well_known_protected_resource, methods=["GET"]),
Route("/.well-known/oauth-authorization-server",
self.well_known_authorization_server, methods=["GET"]),
Route("/register", self.register, methods=["POST"]),
Route("/authorize", self.authorize_get, methods=["GET"]),
Route("/authorize", self.authorize_post, methods=["POST"]),
Route("/token", self.token, methods=["POST"]),
]
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mcp_sliding.py — Serveur MCP Sliding Automation · Chantier C8
=============================================================
Même squelette que mcp_server.py (Context Continuity) : Starlette pur,
JSON-RPC 2.0, Bearer token, compatible Python 3.9.
Transport : Streamable HTTP sur /mcp — port 8767 (Trilium : 8766).
Auth : mêmes variables .env (API_KEY_CLAUDE / API_KEY_LECHAT).
Particularité vs Trilium : le tool get_slide_image renvoie un content
de type IMAGE (base64) — Claude/Le Chat affichent la slide dans la
conversation et peuvent la critiquer.
Usage : python3 mcp_sliding.py → écoute sur 127.0.0.1:8767
Reverse proxy DSM : mcp-sliding.bertha-cloud.fr → 127.0.0.1:8767
"""
import json
import os
from dotenv import load_dotenv
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
import sliding_api as api
from mcp_oauth import OAuthProvider
load_dotenv()
API_KEY_CLAUDE = os.getenv("API_KEY_CLAUDE", "")
API_KEY_LECHAT = os.getenv("API_KEY_LECHAT", "")
VALID_TOKENS = {k: v for k, v in
((API_KEY_CLAUDE, "Claude"), (API_KEY_LECHAT, "LeChat"))
if k}
# OAuth 2.1 (Claude Desktop) — module partagé, état dédié à Sliding
oauth = OAuthProvider(
base_url=os.getenv("OAUTH_BASE_URL",
"https://mcp-sliding.bertha-cloud.fr"),
state_file=os.path.expanduser(
"~/App/Sliding/python-pptx/oauth_state.json"),
security_word=os.getenv("SECURITY_WORD", ""),
realm="Sliding Automation")
PROTOCOL_VERSION = "2025-06-18"
SERVER_INFO = {"name": "Sliding Automation", "version": "1.0.0"}
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
TOOLS = [
{"name": "list_projects",
"description": "Liste les projets Sliding existants (slug, plan "
"présent, dernier PPTX).",
"inputSchema": {"type": "object", "properties": {},
"required": []}},
{"name": "create_project",
"description": "Crée un projet Sliding (dossiers inputs/outputs/"
"assets).",
"inputSchema": {"type": "object", "properties": {
"nom": {"type": "string"}}, "required": ["nom"]}},
{"name": "get_project",
"description": "État complet d'un projet : plan compact, derniers "
"artefacts, documents, assets, journal récent. À "
"appeler en début de session pour reprendre le "
"travail exactement où il en était.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"}}, "required": ["slug"]}},
{"name": "set_plan",
"description": "Persiste le plan compact du projet (source de "
"vérité, rechargée à l'identique). À appeler après "
"chaque évolution structurelle validée du plan.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"},
"plan_compact": {"type": "string"}},
"required": ["slug", "plan_compact"]}},
{"name": "add_document",
"description": "Dépose un document (brief, notes) dans inputs/ du "
"projet. encoding: text (défaut) ou base64.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"}, "filename": {"type": "string"},
"content": {"type": "string"},
"encoding": {"type": "string", "enum": ["text", "base64"]}},
"required": ["slug", "filename", "content"]}},
{"name": "list_assets",
"description": "Images disponibles dans assets/ du projet (pour "
"les layouts image_split/image_full et le freeform).",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"}}, "required": ["slug"]}},
{"name": "formalise",
"description": "Chaîne complète : Designer (choix des layouts) → "
"Encoder structuré → validation → rendu PPTX. "
"plan_markdown = la formalisation slide par slide "
"produite dans la conversation (titres + contenus). "
"Long (1-3 min).",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"},
"plan_markdown": {"type": "string"}},
"required": ["slug", "plan_markdown"]}},
{"name": "revise_slides",
"description": "Révision ciblée : retravaille les slides indiquées"
" puis fusionne et re-rend le deck COMPLET.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"},
"positions": {"type": "array", "items": {"type": "integer"}},
"instruction": {"type": "string"}},
"required": ["slug", "positions", "instruction"]}},
{"name": "render_yaml",
"description": "Rendu direct d'un YAML complet fourni (équivalent "
"--render), pour les cas avancés/freeform.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"}, "yaml": {"type": "string"}},
"required": ["slug", "yaml"]}},
{"name": "preview",
"description": "Génère les aperçus PNG du deck (bloquant, "
"quelques minutes sur le NAS). Retourne la liste "
"des slides ; utiliser ensuite get_slide_image.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"},
"pptx": {"type": "string",
"description": "Chemin optionnel (défaut : dernier "
"PPTX du projet)"}},
"required": ["slug"]}},
{"name": "get_slide_image",
"description": "Renvoie l'image PNG d'une slide (après preview) — "
"affichée directement dans la conversation pour "
"critique visuelle.",
"inputSchema": {"type": "object", "properties": {
"slug": {"type": "string"},
"position": {"type": "integer"},
"pptx": {"type": "string"}},
"required": ["slug", "position"]}},
]
DISPATCH = {
"list_projects": lambda: api.list_projects(),
"create_project": api.create_project,
"get_project": api.get_project,
"set_plan": api.set_plan,
"add_document": api.add_document,
"list_assets": api.list_project_assets,
"formalise": api.formalise,
"revise_slides": lambda slug, positions, instruction:
api.revise(slug, positions, instruction),
"render_yaml": lambda slug, yaml: api.render_yaml(slug, yaml),
"preview": api.preview,
"get_slide_image": api.get_slide_image,
}
# ---------------------------------------------------------------------------
# Handler JSON-RPC MCP
# ---------------------------------------------------------------------------
def check_auth(request: Request):
auth = request.headers.get("authorization", "")
token = auth.replace("Bearer ", "").strip()
# 1) Tokens statiques (Le Chat, clé Claude historique)
if token in VALID_TOKENS:
return VALID_TOKENS[token]
# 2) Tokens OAuth émis (Claude Desktop)
if oauth.check_token(token):
return "Claude-OAuth"
return None
def _result_content(tool_name, result):
"""Contenu MCP : image pour get_slide_image, texte JSON sinon."""
if (tool_name == "get_slide_image" and isinstance(result, dict)
and result.get("b64")):
return [
{"type": "image", "data": result["b64"],
"mimeType": result.get("mime", "image/png")},
{"type": "text",
"text": json.dumps({"filename": result["filename"]},
ensure_ascii=False)},
]
return [{"type": "text",
"text": json.dumps(result, ensure_ascii=False,
indent=2)}]
async def mcp_endpoint(request: Request):
llm = check_auth(request)
if not llm:
return JSONResponse(
{"error": "invalid_token",
"error_description": "Authentication required"},
status_code=401,
headers={"WWW-Authenticate": "Bearer"})
body = await request.json()
method = body.get("method")
req_id = body.get("id")
params = body.get("params", {})
if method == "initialize":
return JSONResponse({
"jsonrpc": "2.0", "id": req_id,
"result": {"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO}})
if method == "notifications/initialized":
return Response(status_code=202)
if method == "tools/list":
return JSONResponse({"jsonrpc": "2.0", "id": req_id,
"result": {"tools": TOOLS}})
if method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
fn = DISPATCH.get(tool_name)
if not fn:
return JSONResponse({
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601,
"message": f"Tool inconnu : {tool_name}"}})
try:
result = fn(**args)
return JSONResponse({
"jsonrpc": "2.0", "id": req_id,
"result": {"content": _result_content(tool_name,
result)}})
except Exception as e:
return JSONResponse({
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32603, "message": str(e)}})
return JSONResponse({
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601,
"message": f"Méthode inconnue : {method}"}})
async def health(request: Request):
return JSONResponse({"status": "ok", "server": "mcp-sliding",
"version": "1.0.0"})
app = Starlette(routes=[
Route("/mcp", mcp_endpoint, methods=["POST"]),
Route("/", mcp_endpoint, methods=["POST"]),
Route("/health", health, methods=["GET"]),
] + oauth.routes())
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8767)
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sliding_api.py — Sliding Pipeline · Chantier C8 (tronc commun)
==============================================================
Façade API NON-INTERACTIVE du pipeline : enveloppe facilitator_v9 sans
le modifier (aucun ask(), aucune boucle terminal) et expose des
fonctions propres, appelables par le serveur MCP (mcp_sliding.py) —
et demain par toute autre enveloppe (web app, CLI batch).
Choix d'architecture : façade plutôt que découpage big-bang du
facilitator — zéro risque de régression sur le flux terminal existant,
réversible, le vrai découpage (R12) reste possible plus tard.
Rôles : le NARRATOR n'est PAS ici — c'est le client MCP (Claude/Le
Chat) qui joue ce rôle et pilote le plan. La façade couvre :
projets, documents/assets, plan compact, formalisation (Designer
Mistral → Encoder structuré → rendu), révision ciblée fusionnée,
preview PNG.
Python 3.9. S'exécute depuis le dossier du pipeline (imports locaux).
"""
import base64
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
import facilitator_v9 as fac
import encoder_schema as enc
# ── Projets ──────────────────────────────────────────────────────────────────
def list_projects():
"""Projets existants avec leur état sommaire."""
root = Path(fac.PROJECTS_DIR)
out = []
if not root.is_dir():
return out
for p in sorted(root.iterdir()):
if not p.is_dir() or not (p / "project_state.json").exists():
continue
try:
state = json.loads((p / "project_state.json")
.read_text(encoding="utf-8"))
except Exception:
state = {}
out.append({
"slug": p.name,
"nom": state.get("nom", p.name),
"sessions": state.get("nb_sessions", 0),
"a_un_plan": bool(state.get("dernier_plan_compact")),
"dernier_pptx": state.get("dernier_pptx") or None,
"maj": state.get("derniere_session") or None,
})
return out
def _project(slug_ou_nom: str) -> "fac.Project":
proj = fac.Project(slug_ou_nom)
if not proj.exists():
raise ValueError(f"Projet introuvable : {slug_ou_nom} "
f"(slug essayé : {proj.slug})")
return proj
def create_project(nom: str):
proj = fac.Project(nom)
existed = proj.exists()
proj.ensure()
(proj.root / "assets").mkdir(exist_ok=True)
if not existed:
proj.log(f"Projet créé via API le "
f"{datetime.now().strftime('%d/%m/%Y %H:%M')}")
return {"slug": proj.slug, "nom": nom,
"status": "existant" if existed else "cree"}
def get_project(slug: str):
"""État complet : plan compact, derniers artefacts, journal."""
proj = _project(slug)
state = proj.load_state()
journal = ""
if proj.journal_file.exists():
lines = proj.journal_file.read_text(
encoding="utf-8").splitlines()
journal = "\n".join(lines[-15:])
docs = sorted(f.name for f in proj.inputs.iterdir()
if f.is_file()) if proj.inputs.is_dir() else []
return {
"slug": proj.slug,
"plan_compact": state.get("dernier_plan_compact", ""),
"dernier_markdown": bool(state.get("dernier_markdown")),
"dernier_yaml": state.get("dernier_yaml") or None,
"dernier_pptx": state.get("dernier_pptx") or None,
"sessions": state.get("nb_sessions", 0),
"documents": docs,
"assets": _asset_names(proj),
"journal_recent": journal,
}
# ── Plan compact ─────────────────────────────────────────────────────────────
def get_plan(slug: str):
proj = _project(slug)
return {"plan_compact": proj.load_state().get(
"dernier_plan_compact", "")}
def set_plan(slug: str, plan_compact: str):
"""Persiste le plan compact — même mécanisme que le facilitator
(source de vérité, rechargée à l'identique en session terminal)."""
proj = _project(slug)
proj.save_plan_compact(plan_compact)
proj.log("Plan compact mis à jour via MCP.")
return {"slug": proj.slug, "status": "plan sauvegardé",
"longueur": len(plan_compact)}
# ── Documents & assets ───────────────────────────────────────────────────────
def add_document(slug: str, filename: str, content: str,
encoding: str = "text"):
"""Dépose un document dans inputs/ (brief, notes…).
encoding='text' (défaut) ou 'base64' pour les binaires."""
proj = _project(slug)
safe = re.sub(r"[^A-Za-z0-9._-]", "_", filename)[:120]
path = proj.inputs / safe
if encoding == "base64":
path.write_bytes(base64.b64decode(content))
else:
path.write_text(content, encoding="utf-8")
return {"slug": proj.slug, "fichier": safe,
"octets": path.stat().st_size}
def _asset_names(proj):
assets = proj.root / "assets"
if not assets.is_dir():
return []
exts = {".png", ".jpg", ".jpeg", ".webp"}
return sorted(p.name for p in assets.iterdir()
if p.suffix.lower() in exts)
def list_project_assets(slug: str):
proj = _project(slug)
info = fac.list_assets(proj) # C4 : noms + dimensions
return {"slug": proj.slug, "assets": info or "aucune image"}
# ── Formalisation : Designer → Encoder → rendu ───────────────────────────────
def formalise(slug: str, plan_markdown: str):
"""Chaîne complète non-interactive :
Designer Mistral (annotation layouts) → Encoder structuré (C1) →
validation → rendu → persistance d'état. Retourne les chemins et
les compteurs. Le plan_markdown = la formalisation produite par le
Narrator-client (Claude/Le Chat)."""
proj = _project(slug)
layouts = fac.load_layouts()
designer = fac.AgentSession(fac.DESIGNER_ID)
annotated = designer.start(plan_markdown)
fac.save_text(annotated, proj.out("designer_annote", "md"))
data, usage, errors = enc.encode_plan(annotated, fac.API_KEY)
if not data.get("slides"):
return {"error": "Aucune slide encodée",
"encoder_errors": errors}
yaml_str = enc.to_yaml(data)
is_valid, message, _ = fac.validate_yaml(yaml_str, layouts)
yaml_path = fac.save_text(yaml_str, proj.out("input", "yaml"))
pptx_path = fac.run_render(yaml_path, proj.root / "assets")
fac.persist_generation(
proj, markdown=plan_markdown, yaml_path=yaml_path,
pptx_path=pptx_path,
journal_entry="Deck formalisé via MCP (Designer + Encoder "
"structuré).")
return {
"slug": proj.slug,
"slides": len(data["slides"]),
"validation": message if is_valid else f"{message}",
"encoder_errors": errors,
"tokens_encoder": usage.get("total_tokens", 0),
"yaml": str(yaml_path),
"pptx": str(pptx_path) if pptx_path else None,
}
def revise(slug: str, positions, instruction: str):
"""Révision ciblée fusionnée (C3) : le Designer retravaille les
slides indiquées à partir du dernier markdown, l'Encoder les
transcrit, merge_revision reconstruit et re-rend le deck COMPLET."""
proj = _project(slug)
state = proj.load_state()
last_md = state.get("dernier_markdown", "")
if not last_md:
return {"error": "Pas de markdown précédent — formalise "
"d'abord."}
positions = sorted({int(p) for p in positions})
pos_txt = ", ".join(str(p) for p in positions)
scope = (f"RÉVISION CIBLÉE — Ne traite QUE les slides {pos_txt}. "
f"Conserve leur numérotation d'origine (SLIDE N — layout)."
f" Instruction : {instruction}")
designer = fac.AgentSession(fac.DESIGNER_ID)
annotated = designer.start(f"{last_md}\n\n---\n{scope}")
fac.save_text(annotated, proj.out("designer_revision", "md"))
data, usage, errors = enc.encode_plan(
annotated, fac.API_KEY, only_positions=positions)
if not data.get("slides"):
return {"error": "Aucune slide encodée",
"encoder_errors": errors}
partial_path = fac.save_text(enc.to_yaml(data),
proj.out("revision_ciblee", "yaml"))
fused = fac.merge_revision(proj, partial_path)
target = fused or partial_path
pptx_path = fac.run_render(target, proj.root / "assets")
if fused:
fac.persist_generation(
proj, yaml_path=fused, pptx_path=pptx_path,
journal_entry=f"Révision MCP des slides {pos_txt} — deck "
f"complet régénéré.")
return {
"slug": proj.slug,
"slides_revisees": positions,
"fusion": bool(fused),
"encoder_errors": errors,
"pptx": str(pptx_path) if pptx_path else None,
}
# ── Rendu direct & preview ───────────────────────────────────────────────────
def render_yaml(slug: str, yaml_str: str):
"""Rendu direct d'un YAML fourni (équivalent --render)."""
proj = _project(slug)
layouts = fac.load_layouts()
is_valid, message, _ = fac.validate_yaml(yaml_str, layouts)
yaml_path = fac.save_text(yaml_str, proj.out("input", "yaml"))
pptx_path = fac.run_render(yaml_path, proj.root / "assets")
return {"slug": proj.slug,
"validation": message if is_valid else f"{message}",
"pptx": str(pptx_path) if pptx_path else None}
def preview(slug: str, pptx: Optional[str] = None):
"""Aperçus PNG (bloquant, quelques minutes sur le NAS).
pptx=None → dernier PPTX du projet."""
proj = _project(slug)
target = Path(pptx) if pptx else Path(
proj.load_state().get("dernier_pptx") or "")
if not target or not target.exists():
return {"error": "PPTX introuvable — formalise ou précise le "
"chemin."}
ok = fac.run_preview(target, wait=True)
if not ok:
return {"error": "La génération des aperçus a échoué (voir "
"logs NAS)."}
prev_dir = target.parent / f"{target.stem}_previews"
pngs = sorted(prev_dir.glob("slide-*.png"))
return {"slug": proj.slug, "dossier": str(prev_dir),
"slides": [p.name for p in pngs]}
def get_slide_image(slug: str, position: int,
pptx: Optional[str] = None):
"""Retourne le PNG (base64) d'une slide du dernier preview —
consommé par le tool MCP qui le renvoie en content type image."""
proj = _project(slug)
target = Path(pptx) if pptx else Path(
proj.load_state().get("dernier_pptx") or "")
prev_dir = target.parent / f"{target.stem}_previews"
png = prev_dir / f"slide-{int(position):02d}.png"
if not png.exists():
return {"error": f"{png.name} introuvable — lance preview "
f"d'abord."}
return {"filename": png.name, "mime": "image/png",
"b64": base64.b64encode(png.read_bytes()).decode("ascii")}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
cd /volume1/homes/Master/App/Sliding/python-pptx
pkill -f "uvicorn mcp_sliding:app" 2>/dev/null; fuser -k 8768/tcp 2>/dev/null
sleep 2
source venv/bin/activate
uvicorn mcp_sliding:app --host 127.0.0.1 --port 8768 >> /volume1/homes/Master/App/Sliding/python-pptx/mcp.log 2>&1 &
echo $! > /volume1/homes/Master/App/Sliding/python-pptx/mcp.pid
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# Watchdog Sliding — verifie le MCP (8768) et le relance s'il ne repond pas.
# Patient : plusieurs essais avant de conclure a la mort (le NAS peut ramer
# pendant un rendu LibreOffice — un faux positif tuerait le service en plein
# travail). Lance par tache planifiee DSM toutes les 5 min.
BASE=/volume1/homes/Master/App/Sliding/python-pptx
LOG="$BASE/watchdog.log"
TS=$(date '+%Y-%m-%d %H:%M:%S')
# Verifie /health avec plusieurs essais. Retourne 0 si OK, 1 sinon.
wait_health() {
URL="$1"
TRIES="$2"
i=0
while [ "$i" -lt "$TRIES" ]; do
if curl -s -f -m 5 "$URL" > /dev/null 2>&1; then
return 0
fi
i=$((i + 1))
sleep 3
done
return 1
}
check_and_restart() {
NAME="$1" # libelle
URL="$2" # url health
STARTER="$3" # script de demarrage
# Check patient (3 essais, ~24s) : evite les faux positifs sous charge
if wait_health "$URL" 3; then
return 0
fi
echo "$TS [$NAME] KO sur $URL — relance via $STARTER" >> "$LOG"
sh "$BASE/$STARTER"
if wait_health "$URL" 5; then
echo "$TS [$NAME] relance OK" >> "$LOG"
else
echo "$TS [$NAME] ECHEC relance — toujours muet apres ~30s" >> "$LOG"
fi
}
check_and_restart "mcp" "http://127.0.0.1:8768/health" "start_mcp.sh"