From 6e8167a91c71d2bc435810be5212c0ad24217d9c Mon Sep 17 00:00:00 2001 From: Master Date: Sun, 12 Jul 2026 08:04:41 +0200 Subject: [PATCH] feat: serveur MCP Sliding avec OAuth 2.1 partage (C8) --- .gitignore | 4 + mcp_oauth.py | 269 ++++++++++++++++++++++++++++++++++++++++++++++ mcp_sliding.py | 254 +++++++++++++++++++++++++++++++++++++++++++ sliding_api.py | 286 +++++++++++++++++++++++++++++++++++++++++++++++++ start_mcp.sh | 7 ++ watchdog.sh | 46 ++++++++ 6 files changed, 866 insertions(+) create mode 100644 mcp_oauth.py create mode 100644 mcp_sliding.py create mode 100644 sliding_api.py create mode 100644 start_mcp.sh create mode 100644 watchdog.sh diff --git a/.gitignore b/.gitignore index ebb0a8c..cc31506 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ projets/*/inputs/ *.bak_* assets/ projets/ +oauth_state.json +mcp.pid +mcp.log +watchdog.log diff --git a/mcp_oauth.py b/mcp_oauth.py new file mode 100644 index 0000000..bd7958c --- /dev/null +++ b/mcp_oauth.py @@ -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 = """ +Autorisation {realm} + +

Autorisation {realm}

+

Saisissez le mot de securite pour autoriser l acces.

+{error} +
+ + + + + + + +
""" + + +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='

Mot de securite incorrect.

', + 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"]), + ] diff --git a/mcp_sliding.py b/mcp_sliding.py new file mode 100644 index 0000000..58d9fea --- /dev/null +++ b/mcp_sliding.py @@ -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) diff --git a/sliding_api.py b/sliding_api.py new file mode 100644 index 0000000..4a1c4f1 --- /dev/null +++ b/sliding_api.py @@ -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")} diff --git a/start_mcp.sh b/start_mcp.sh new file mode 100644 index 0000000..e3b34bf --- /dev/null +++ b/start_mcp.sh @@ -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 diff --git a/watchdog.sh b/watchdog.sh new file mode 100644 index 0000000..4a2672f --- /dev/null +++ b/watchdog.sh @@ -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"