Files

255 lines
10 KiB
Python
Raw Permalink Normal View History

#!/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)