feat: serveur MCP Sliding avec OAuth 2.1 partage (C8)
This commit is contained in:
@@ -13,3 +13,7 @@ projets/*/inputs/
|
|||||||
*.bak_*
|
*.bak_*
|
||||||
assets/
|
assets/
|
||||||
projets/
|
projets/
|
||||||
|
oauth_state.json
|
||||||
|
mcp.pid
|
||||||
|
mcp.log
|
||||||
|
watchdog.log
|
||||||
|
|||||||
+269
@@ -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
@@ -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
@@ -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")}
|
||||||
@@ -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
@@ -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"
|
||||||
Reference in New Issue
Block a user